@aglyn/plugins-marketing 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 (33) hide show
  1. package/package.json +15 -15
  2. package/src/lib/components/campaign-detail-card.js +106 -36
  3. package/src/lib/components/campaign-detail-card.js.map +1 -1
  4. package/src/lib/components/campaign-queries.d.ts +11 -0
  5. package/src/lib/components/campaign-queries.js +13 -0
  6. package/src/lib/components/campaign-queries.js.map +1 -1
  7. package/src/lib/components/campaigns-card.d.ts +8 -6
  8. package/src/lib/components/campaigns-card.js +283 -125
  9. package/src/lib/components/campaigns-card.js.map +1 -1
  10. package/src/lib/components/emails-list-card.d.ts +11 -15
  11. package/src/lib/components/emails-list-card.js +139 -142
  12. package/src/lib/components/emails-list-card.js.map +1 -1
  13. package/src/lib/components/host-experiments-card.component.js +107 -78
  14. package/src/lib/components/host-experiments-card.component.js.map +1 -1
  15. package/src/lib/components/org-experiments-card.js +7 -3
  16. package/src/lib/components/org-experiments-card.js.map +1 -1
  17. package/src/lib/model/campaign-list-query.d.ts +99 -0
  18. package/src/lib/model/campaign-list-query.js +373 -0
  19. package/src/lib/model/campaign-list-query.js.map +1 -0
  20. package/src/lib/model/experiment-list-query.d.ts +22 -0
  21. package/src/lib/model/experiment-list-query.js +85 -0
  22. package/src/lib/model/experiment-list-query.js.map +1 -0
  23. package/src/lib/server/campaign-manage.d.ts +1 -1
  24. package/src/lib/server/campaign-manage.js +30 -18
  25. package/src/lib/server/campaign-manage.js.map +1 -1
  26. package/src/lib/server/campaign-send.d.ts +7 -0
  27. package/src/lib/server/campaign-send.js +17 -5
  28. package/src/lib/server/campaign-send.js.map +1 -1
  29. package/src/lib/server/email-events.js +3 -1
  30. package/src/lib/server/email-events.js.map +1 -1
  31. package/src/lib/components/email-send-filter.d.ts +0 -32
  32. package/src/lib/components/email-send-filter.js +0 -55
  33. package/src/lib/components/email-send-filter.js.map +0 -1
@@ -22,32 +22,38 @@ import { buildRoute, createResourceUid, pluginDocsHelp, Route } from "@aglyn/agl
22
22
  * `@aglyn/tenant-feature-instance` wholesale to stage its Firestore hooks
23
23
  * would otherwise lose them, and neither is a hook.
24
24
  */ import { ceilingedWindow, collectionCeiling } from "@aglyn/tenant-feature-instance/hooks/host-collection-queries";
25
+ import { useListQuery } from "@aglyn/tenant-feature-instance/hooks/use-list-query";
25
26
  import { mdiDeleteOutline, mdiEyeOutline } from "@aglyn/shared-data-mdi";
26
27
  import { AppLink, CardDisplay, MdiIcon, useConfirmationContext } from "@aglyn/shared-ui-jsx";
27
28
  import ListFilterChips from "@aglyn/shared-ui-jsx/components/list-filter-chips.component";
29
+ import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
30
+ import ListQueryNotices, { listQueryRefusals } from "@aglyn/shared-ui-jsx/components/list-query-notices.component";
28
31
  import { ListRowActions, ListTable, listActionsColumn } from "@aglyn/shared-ui-jsx/components/list-table.component";
29
- import { inMemoryListField } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
30
- import { useListRowsFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-rows-filter";
32
+ import { hiddenFilterVisibility } from "@aglyn/shared-ui-jsx/const/list-filter";
33
+ import { listFilterGridColumns } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
34
+ import { useListGridFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-grid-filter";
31
35
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
32
- import { Alert, Button, Chip, Stack, Typography } from "@mui/material";
33
- import { collection, limit, query, setDoc } from "firebase/firestore";
36
+ import { Alert, Button, Chip, Stack, ToggleButton, ToggleButtonGroup, Typography } from "@mui/material";
37
+ import { collection, limit, query, setDoc, where } from "firebase/firestore";
34
38
  import { useCallback, useMemo, useState } from "react";
35
39
  import { useRouter } from "next/navigation";
36
40
  import { useConsoleHostRoute, useFirestore, useFirestoreCollection, useUser } from "@aglyn/tenant-feature-instance";
37
41
  import { campaignListRows, campaignSiteIds, campaignVisibleTo } from "@aglyn/shared-ui-email-campaigns/model";
38
42
  import { CreateArtifactDrawer } from "@aglyn/shared-ui-jsx-forms";
39
- import { campaignContainerDoc, campaignContainersQuery, campaignSendsQuery } from "./campaign-queries.js";
43
+ import { CAMPAIGN_SITE_SEARCH_NOTICE, SINGLE_SENDS_BASE, campaignContainerSearchFields, campaignContainersListQuery, campaignContainersScope, campaignSendsScope, campaignSingleSendsListQuery, campaignSiteSearchClause } from "../model/campaign-list-query.js";
44
+ import { campaignContainerDoc, campaignContainersCollection, campaignSendsCollection } from "./campaign-queries.js";
40
45
  import { orgSiteName, orgSiteOptions, topicCatalogHostId, useMarketingOrgId, useMarketingOrgMount } from "./marketing-org-mount.js";
41
46
  import { useCampaignManageApi } from "./use-campaign-send-api.js";
42
47
  import { useCampaignTopicOptions } from "./use-campaign-topic-options.js";
43
48
  /**
44
- * How many sends the list reads.
49
+ * How many emails one read of a page's campaigns covers, for their figures.
45
50
  *
46
- * A CEILING, not a page size — see the query, which explains why this list
47
- * cannot be paged by the server until a send carries one date field every
48
- * writer stamps.
49
- */ const CAMPAIGN_CEILING = 30;
50
- /** How many campaign containers the list reads. */ const CONTAINER_CEILING = 50;
51
+ * A ceiling on the ROLLUP read, not on the list: the list is paged by its
52
+ * own query, and this reads the emails of the campaigns on the page on
53
+ * screen, thirty campaigns to a read (`in` takes thirty ids), so the largest
54
+ * page is two reads of this many. Past it, the figures say so.
55
+ */ const ROLLUP_CEILING = 50;
56
+ /** Firestore's cap on an `in` clause's values. */ const IN_LIMIT = 30;
51
57
  const formatDay = (ms)=>ms === null ? '' : new Date(ms).toLocaleDateString();
52
58
  /** A rolled-up figure, or an em dash where nothing recorded one. */ const figure = (value)=>value.value === null ? '—' : value.value.toLocaleString();
53
59
  const WINDOW_LABEL = {
@@ -57,44 +63,21 @@ const WINDOW_LABEL = {
57
63
  ended: 'Ended'
58
64
  };
59
65
  /*
60
- * What the campaigns grid's Filters panel offers (AGL-3317). The card reads
61
- * every campaign under its ceiling, so the panel and the search answer over
62
- * all of them. A campaign's lists are ids, matched member by member.
63
- */ const CAMPAIGN_FILTER_FIELDS = [
64
- inMemoryListField('name', 'text'),
65
- inMemoryListField('windowState', 'select'),
66
- _extends({}, inMemoryListField('listIds', 'select'), {
67
- tokensPath: 'listIds',
68
- verbatimTokens: true
69
- }),
70
- inMemoryListField('kind', 'select'),
71
- inMemoryListField('sitesLabel', 'text')
72
- ];
73
- const CAMPAIGN_FILTER_HEADERS = {
66
+ * What the campaigns grid's Filters panel offers, each clause and the search
67
+ * on the list's Firestore query (AGL-3321, `campaignContainersListQuery` and
68
+ * `campaignSingleSendsListQuery`). The Window column is derived from a
69
+ * campaign's dates against the clock, which no query can ask, so it is not
70
+ * offered; the one date range is Created, the list's own order.
71
+ */ const CAMPAIGN_FILTER_HEADERS = {
74
72
  name: 'Campaign',
75
- windowState: 'Window',
76
73
  listIds: 'Lists',
77
- kind: 'Kind',
78
- sitesLabel: 'Sites'
74
+ sitesLabel: 'Sites',
75
+ createdAtMs: 'Created'
76
+ };
77
+ const NO_CLAUSES = {
78
+ campaign: [],
79
+ single: []
79
80
  };
80
- const CAMPAIGN_KIND_OPTIONS = [
81
- {
82
- value: 'campaign',
83
- label: 'Campaign'
84
- },
85
- {
86
- value: 'single',
87
- label: 'Single send'
88
- }
89
- ];
90
- const CAMPAIGN_WINDOW_OPTIONS = Object.keys(WINDOW_LABEL).map((state)=>({
91
- value: state,
92
- label: WINDOW_LABEL[state]
93
- }));
94
- /** What the quick search reads on a campaign row. */ const CAMPAIGN_SEARCH_FIELDS = [
95
- 'name',
96
- 'sitesLabel'
97
- ];
98
81
  /**
99
82
  * THE CAMPAIGNS LIST.
100
83
  *
@@ -103,12 +86,14 @@ const CAMPAIGN_WINDOW_OPTIONS = Object.keys(WINDOW_LABEL).map((state)=>({
103
86
  * messages. Composing is not here: a message belongs to a campaign, and the
104
87
  * campaign's own page is where one is written.
105
88
  *
106
- * The rows come from two collections, and the second is why this reads
107
- * without a migration. `emailCampaigns` holds the containers.
108
- * `campaigns` holds every send, including thousands written before containers
109
- * existed; those carry no container id, and `campaignListRows` presents each
110
- * as a campaign of one rather than dropping it. Nothing is rewritten, so no
111
- * unsubscribe link and no pasted report URL stops resolving.
89
+ * The rows come from two collections. `emailCampaigns` holds the
90
+ * containers. `campaigns` holds every send, including thousands written
91
+ * before containers existed; those carry no container (`emailCampaignId`
92
+ * null), and `campaignListRows` presents each as a campaign of one rather
93
+ * than dropping it. The two are two queries, so the table lists one kind at a
94
+ * time — Campaigns or Single sends — each paged by its own query (AGL-3321).
95
+ * No send is re-filed, so no unsubscribe link and no pasted report URL stops
96
+ * resolving.
112
97
  *
113
98
  * ## At two levels
114
99
  *
@@ -134,42 +119,132 @@ const CAMPAIGN_WINDOW_OPTIONS = Object.keys(WINDOW_LABEL).map((state)=>({
134
119
  const { orgId } = useMarketingOrgId(hostId);
135
120
  // A container is the org's, so deleting one from the org hub names the org.
136
121
  const manageApi = useCampaignManageApi(hostId, orgId);
137
- /*
138
- * ORDERED AND CEILINGED, and not orderable on any DATE.
122
+ /*==========================================
123
+ * TWO KINDS OF ROW, EACH ITS OWN QUERY (AGL-3321).
139
124
  *
140
- * No field here is on every send: a sent one is written `{status:'sent',
141
- * sentAt}` and a scheduled one `{status:'scheduled', sendAtMs}`, by the two
142
- * branches of `campaign-send.ts`, and there is no `createdAt` at all.
143
- * `orderBy` on either would not mis-sort the list, it would DROP half of it.
125
+ * A campaign is a CONTAINER (`emailCampaigns`); a single send is an email
126
+ * filed under none (`campaigns` where `emailCampaignId == null`). The two
127
+ * live in different collections, and one Firestore query reads one
128
+ * collection — so a table that merged them could page neither, and every
129
+ * filter would be matched over whatever window of each it happened to
130
+ * hold. The toggle above the table picks the kind, and the table shows a
131
+ * page of that kind's query: newest first on `createdAtMs`, every clause
132
+ * and the search word on the query, paged by it. Same table, same
133
+ * columns; a single send still reads as a campaign of one.
144
134
  *
145
- * A bare `limit(30)` is answered in DOCUMENT-ID order, so the window is
146
- * thirty sends chosen by id and then sorted by date, which reads as the most
147
- * recent thirty and is not. `collectionCeiling` returns that same thirty —
148
- * document-id order is what the bare cap already gave — but it says so,
149
- * which is what stops the next edit reaching for `sentAt`, and it probes one
150
- * past the ceiling so the reader is told the history is longer.
151
- */ const { data: sendDocs } = useFirestoreCollection(()=>{
152
- const sends = campaignSendsQuery(firestore, orgId, hostId);
153
- return sends ? collectionCeiling(sends, CAMPAIGN_CEILING) : null;
154
- }, [
135
+ * Each kind keeps its own clauses (their Sites values differ: a campaign
136
+ * is asked by scope token, a send by the site it was sent as); the search
137
+ * words are the reader's and carry across.
138
+ *=========================================*/ const [kind, setKind] = useState('campaign');
139
+ const [clausesByKind, setClausesByKind] = useState(NO_CLAUSES);
140
+ const [searchWords, setSearchWords] = useState([]);
141
+ const setKindClauses = useCallback((next)=>setClausesByKind((current)=>_extends({}, current, {
142
+ [kind]: next
143
+ })), [
144
+ kind
145
+ ]);
146
+ const gridFilter = useListGridFilter({
147
+ clauses: clausesByKind[kind],
148
+ onChange: setKindClauses,
149
+ selectFields: [
150
+ 'listIds',
151
+ 'sitesLabel'
152
+ ],
153
+ search: {
154
+ words: searchWords,
155
+ onChange: setSearchWords
156
+ }
157
+ });
158
+ const containerDeclaration = campaignContainersListQuery(!hostId);
159
+ const singleDeclaration = campaignSingleSendsListQuery(!hostId);
160
+ const declaration = kind === 'campaign' ? containerDeclaration : singleDeclaration;
161
+ /*
162
+ * A site's campaigns are searched by the START of the name: a range beside
163
+ * the site's scope clause, the one array clause the rules can prove for a
164
+ * site collaborator (see `campaign-list-query.ts`). The org hub keeps the
165
+ * word search.
166
+ */ const siteSearch = hostId && kind === 'campaign' ? campaignSiteSearchClause(gridFilter.searchWords) : null;
167
+ const containerPage = useListQuery({
168
+ collection: kind === 'campaign' ? campaignContainersCollection(firestore, orgId) : null,
169
+ declaration: containerDeclaration,
170
+ request: {
171
+ clauses: kind === 'campaign' ? siteSearch ? [
172
+ siteSearch,
173
+ ...gridFilter.clauses
174
+ ] : gridFilter.clauses : [],
175
+ search: hostId ? [] : gridFilter.searchWords,
176
+ base: campaignContainersScope(hostId)
177
+ },
178
+ deps: [
179
+ firestore,
180
+ orgId,
181
+ hostId,
182
+ kind
183
+ ],
184
+ idField: '$id'
185
+ });
186
+ const singlePage = useListQuery({
187
+ collection: kind === 'single' ? campaignSendsCollection(firestore, orgId) : null,
188
+ declaration: singleDeclaration,
189
+ request: {
190
+ clauses: kind === 'single' ? gridFilter.clauses : [],
191
+ search: gridFilter.searchWords,
192
+ base: [
193
+ SINGLE_SENDS_BASE,
194
+ ...campaignSendsScope(hostId)
195
+ ]
196
+ },
197
+ deps: [
198
+ firestore,
199
+ orgId,
200
+ hostId,
201
+ kind
202
+ ],
203
+ idField: '$id'
204
+ });
205
+ const page = kind === 'campaign' ? containerPage : singlePage;
206
+ const filtering = gridFilter.clauses.length > 0 || gridFilter.searchWords.some((word)=>word.trim());
207
+ /*
208
+ * THE FIGURES OF THE CAMPAIGNS ON THE PAGE ON SCREEN.
209
+ *
210
+ * A container stores no counters; its row sums the emails filed under it.
211
+ * Those are read for the page's campaigns only — `emailCampaignId in` the
212
+ * ids on screen, thirty to a read, and under a site the ones sent as it —
213
+ * and never used to filter: the page is already the query's answer. Past
214
+ * `ROLLUP_CEILING` emails a read stops, and the table says the figures
215
+ * are partial.
216
+ */ const pageIds = useMemo(()=>kind === 'campaign' ? containerPage.rows.map((row)=>String(row.$id)) : [], [
217
+ kind,
218
+ containerPage.rows
219
+ ]);
220
+ const rollupRead = (ids)=>()=>{
221
+ const sends = campaignSendsCollection(firestore, orgId);
222
+ if (!sends || !ids.length) return null;
223
+ return collectionCeiling(query(sends, where('emailCampaignId', 'in', ids), ...hostId ? [
224
+ where('hostId', '==', hostId)
225
+ ] : []), ROLLUP_CEILING);
226
+ };
227
+ const firstIds = pageIds.slice(0, IN_LIMIT);
228
+ const restIds = pageIds.slice(IN_LIMIT, IN_LIMIT * 2);
229
+ const { data: firstSendDocs } = useFirestoreCollection(rollupRead(firstIds), [
155
230
  firestore,
156
231
  orgId,
157
- hostId
232
+ hostId,
233
+ firstIds.join(',')
158
234
  ], {
159
235
  idField: '$id'
160
236
  });
161
- const { rows: readSends, truncated: sendsTruncated } = ceilingedWindow(sendDocs, CAMPAIGN_CEILING);
162
- const { data: campaignDocs } = useFirestoreCollection(()=>{
163
- const containers = campaignContainersQuery(firestore, orgId, hostId);
164
- return containers ? collectionCeiling(containers, CONTAINER_CEILING) : null;
165
- }, [
237
+ const { data: restSendDocs } = useFirestoreCollection(rollupRead(restIds), [
166
238
  firestore,
167
239
  orgId,
168
- hostId
240
+ hostId,
241
+ restIds.join(',')
169
242
  ], {
170
243
  idField: '$id'
171
244
  });
172
- const { rows: readCampaigns, truncated: campaignsTruncated } = ceilingedWindow(campaignDocs, CONTAINER_CEILING);
245
+ const firstSends = ceilingedWindow(firstSendDocs, ROLLUP_CEILING);
246
+ const restSends = ceilingedWindow(restSendDocs, ROLLUP_CEILING);
247
+ const rollupTruncated = firstSends.truncated || restSends.truncated;
173
248
  // Org email lists, so a row can name what it is aimed at rather than
174
249
  // showing ids.
175
250
  const { data: listDocs } = useFirestoreCollection(()=>orgId ? query(collection(firestore, 'orgs', orgId, 'lists'), limit(50)) : null, [
@@ -216,40 +291,66 @@ const CAMPAIGN_WINDOW_OPTIONS = Object.keys(WINDOW_LABEL).map((state)=>({
216
291
  }), [
217
292
  listDocs
218
293
  ]);
219
- const rows = useMemo(()=>{
220
- const campaigns = readCampaigns.filter((campaign)=>!campaign.deletedAt);
221
- const byId = new Map(campaigns.map((campaign)=>[
222
- campaign.$id,
223
- campaign
294
+ /*
295
+ * The page's rows, in the QUERY's order. `campaignListRows` builds each
296
+ * row — a container with its emails summed, or a single send as a
297
+ * campaign of one — and sorts by date, which is not this list's order, so
298
+ * the page is put back in the order the query returned it.
299
+ */ const rows = useMemo(()=>{
300
+ const read = page.rows;
301
+ const order = new Map(read.map((row, at)=>[
302
+ String(row.$id),
303
+ at
304
+ ]));
305
+ const built = kind === 'campaign' ? campaignListRows(read, [
306
+ ...firstSends.rows,
307
+ ...restSends.rows
308
+ ], Date.now()) : campaignListRows([], read, Date.now());
309
+ const byId = new Map(read.map((row)=>[
310
+ String(row.$id),
311
+ row
224
312
  ]));
225
- return campaignListRows(campaigns, readSends, Date.now()).map((row)=>_extends({}, row, {
313
+ return built.filter((row)=>order.has(row.id)).sort((a, b)=>{
314
+ var _order_get, _order_get1;
315
+ return ((_order_get = order.get(a.id)) != null ? _order_get : 0) - ((_order_get1 = order.get(b.id)) != null ? _order_get1 : 0);
316
+ }).map((row)=>_extends({}, row, {
226
317
  $id: row.id,
227
- kind: row.legacy ? 'single' : 'campaign',
228
318
  sitesLabel: orgMount ? campaignSitesLabel(orgMount, row, byId.get(row.id)) : ''
229
319
  }));
230
320
  }, [
231
- readCampaigns,
232
- readSends,
321
+ kind,
322
+ page.rows,
323
+ firstSends.rows,
324
+ restSends.rows,
233
325
  orgMount
234
326
  ]);
235
- const filterOptions = useMemo(()=>({
236
- windowState: CAMPAIGN_WINDOW_OPTIONS,
327
+ /*
328
+ * The choices the picked fields offer. A campaign's Sites are asked by
329
+ * scope token — `org` is placed on every site — and a single send's by
330
+ * the site it was sent as.
331
+ */ const filterOptions = useMemo(()=>{
332
+ if (!orgMount) return {};
333
+ const sites = orgSiteOptions(orgMount);
334
+ return kind === 'campaign' ? {
237
335
  listIds: listOptions,
238
- kind: CAMPAIGN_KIND_OPTIONS
239
- }), [
240
- listOptions
241
- ]);
242
- // Sites is a column on the org hub only; under a site it would say one thing.
243
- const filterFields = useMemo(()=>orgMount ? CAMPAIGN_FILTER_FIELDS : CAMPAIGN_FILTER_FIELDS.filter((field)=>field.column !== 'sitesLabel'), [
336
+ sitesLabel: [
337
+ {
338
+ value: 'org',
339
+ label: 'Every site'
340
+ },
341
+ ...sites.map((site)=>({
342
+ value: `host:${site.value}`,
343
+ label: site.label
344
+ }))
345
+ ]
346
+ } : {
347
+ sitesLabel: sites
348
+ };
349
+ }, [
350
+ kind,
351
+ listOptions,
244
352
  orgMount
245
353
  ]);
246
- const listFilter = useListRowsFilter({
247
- rows,
248
- fields: filterFields,
249
- options: filterOptions,
250
- headers: CAMPAIGN_FILTER_HEADERS,
251
- search: CAMPAIGN_SEARCH_FIELDS
252
- });
253
354
  const [createError, setCreateError] = useState(null);
254
355
  const [creating, setCreating] = useState(false);
255
356
  /** The campaign a delete is in flight for, so its row menu can say so. */ const [deletingId, setDeletingId] = useState('');
@@ -286,11 +387,19 @@ const CAMPAIGN_WINDOW_OPTIONS = Object.keys(WINDOW_LABEL).map((state)=>({
286
387
  setCreating(true);
287
388
  setCreateError(null);
288
389
  const id = createResourceUid();
390
+ /*
391
+ * Where it is placed. From a site's hub, that site — the creator is
392
+ * standing on it, and a campaign that appeared on every sibling site
393
+ * would be a decision nobody made. From the org hub, the sites picked,
394
+ * and every site when none are.
395
+ */ const visibleTo = campaignVisibleTo(hostId ? [
396
+ hostId
397
+ ] : Array.isArray(values.siteIds) ? values.siteIds.map(String) : null);
289
398
  try {
290
399
  var _ref;
291
400
  await setDoc(campaignContainerDoc(firestore, orgId, id), _extends({
292
401
  name
293
- }, Number.isFinite(startAtMs) && startAtMs !== null ? {
402
+ }, campaignContainerSearchFields(name), Number.isFinite(startAtMs) && startAtMs !== null ? {
294
403
  startAtMs
295
404
  } : {}, Number.isFinite(endAtMs) && endAtMs !== null ? {
296
405
  endAtMs
@@ -299,14 +408,7 @@ const CAMPAIGN_WINDOW_OPTIONS = Object.keys(WINDOW_LABEL).map((state)=>({
299
408
  }, values.topicId ? {
300
409
  topicId: String(values.topicId)
301
410
  } : {}, {
302
- /*
303
- * Where it is placed. From a site's hub, that site — the creator is
304
- * standing on it, and a campaign that appeared on every sibling
305
- * site would be a decision nobody made. From the org hub, the sites
306
- * picked, and every site when none are.
307
- */ visibleTo: campaignVisibleTo(hostId ? [
308
- hostId
309
- ] : Array.isArray(values.siteIds) ? values.siteIds.map(String) : null),
411
+ visibleTo,
310
412
  createdAtMs: Date.now(),
311
413
  createdBy: String((_ref = user == null ? void 0 : user.uid) != null ? _ref : '')
312
414
  }));
@@ -663,6 +765,7 @@ const CAMPAIGN_WINDOW_OPTIONS = Object.keys(WINDOW_LABEL).map((state)=>({
663
765
  handleDelete,
664
766
  orgMount
665
767
  ]);
768
+ /** Nothing of this kind stored at all — not a filter that matched nothing. */ const none = page.status !== 'loading' && !rows.length && !filtering && page.page === 0;
666
769
  return /*#__PURE__*/ _jsxs(CardDisplay, {
667
770
  header: 'Campaigns',
668
771
  help: pluginDocsHelp('emailCampaigns', {
@@ -687,35 +790,90 @@ const CAMPAIGN_WINDOW_OPTIONS = Object.keys(WINDOW_LABEL).map((state)=>({
687
790
  /*#__PURE__*/ _jsxs(Stack, {
688
791
  spacing: 1.5,
689
792
  children: [
690
- /*#__PURE__*/ _jsx(ListFilterChips, _extends({}, listFilter.chipsProps)),
691
- /*#__PURE__*/ _jsx(ListTable, _extends({
692
- rows: listFilter.rows,
693
- columns: listFilter.filterColumns(columns),
694
- onOpen: (id)=>openCampaign(id)
695
- }, listFilter.gridProps, {
793
+ /*#__PURE__*/ _jsxs(ToggleButtonGroup, {
794
+ exclusive: true,
795
+ size: "small",
796
+ value: kind,
797
+ onChange: (_event, next)=>{
798
+ if (next) setKind(next);
799
+ },
800
+ "aria-label": "Which rows the table lists",
801
+ sx: {
802
+ alignSelf: 'flex-start'
803
+ },
804
+ children: [
805
+ /*#__PURE__*/ _jsx(ToggleButton, {
806
+ value: "campaign",
807
+ children: 'Campaigns'
808
+ }),
809
+ /*#__PURE__*/ _jsx(ToggleButton, {
810
+ value: "single",
811
+ children: 'Single sends'
812
+ })
813
+ ]
814
+ }),
815
+ /*#__PURE__*/ _jsx(ListFilterChips, {
816
+ fields: declaration.fields,
817
+ headers: CAMPAIGN_FILTER_HEADERS,
818
+ clauses: gridFilter.clauses,
819
+ onChange: gridFilter.setClauses,
820
+ options: filterOptions
821
+ }),
822
+ /*#__PURE__*/ _jsx(ListQueryNotices, {
823
+ refused: listQueryRefusals(page.plan.refused, {
824
+ fields: declaration.fields,
825
+ headers: CAMPAIGN_FILTER_HEADERS,
826
+ options: filterOptions
827
+ }),
828
+ notices: siteSearch ? [
829
+ CAMPAIGN_SITE_SEARCH_NOTICE,
830
+ ...page.plan.notices
831
+ ] : page.plan.notices
832
+ }),
833
+ /*#__PURE__*/ _jsx(ListTable, {
834
+ rows: rows,
835
+ columns: listFilterGridColumns(columns, declaration.fields, filterOptions, CAMPAIGN_FILTER_HEADERS),
836
+ onOpen: (id)=>openCampaign(id),
837
+ loading: page.status === 'loading',
838
+ /*
839
+ * The panel and the search are the grid's; every clause and the
840
+ * search word are on the list's query (AGL-3321), and the grid
841
+ * neither filters nor sorts the page it is handed.
842
+ */ filterMode: "server",
843
+ filterModel: gridFilter.filterModel,
844
+ onFilterModelChange: gridFilter.onFilterModelChange,
845
+ quickFilter: true,
846
+ disableColumnSorting: true,
847
+ hideFooter: true,
696
848
  initialState: {
697
849
  columns: {
698
- columnVisibilityModel: {
699
- kind: false
700
- }
850
+ columnVisibilityModel: hiddenFilterVisibility(declaration.fields, columns.map((column)=>column.field))
701
851
  }
702
852
  },
703
- noRowsLabel: rows.length ? 'No campaigns match these filters' : 'No campaigns yet',
704
- noRowsDescription: rows.length ? undefined : 'A campaign groups the emails you send to a set of lists.',
705
- noRowsAction: rows.length ? undefined : /*#__PURE__*/ _jsx(Button, {
853
+ noRowsLabel: none ? kind === 'campaign' ? 'No campaigns yet' : 'No single sends' : kind === 'campaign' ? 'No campaigns match these filters' : 'No single sends match these filters',
854
+ noRowsDescription: none ? kind === 'campaign' ? 'A campaign groups the emails you send to a set of lists.' : 'An email filed under no campaign is listed here.' : undefined,
855
+ noRowsAction: none && kind === 'campaign' ? /*#__PURE__*/ _jsx(Button, {
706
856
  variant: "contained",
707
857
  onClick: ()=>setCreateOpen(true),
708
858
  children: 'Create campaign'
709
- })
710
- })),
859
+ }) : undefined
860
+ }),
861
+ /*#__PURE__*/ _jsx(ListPagination, {
862
+ page: page.page,
863
+ pageSize: page.pageSize,
864
+ rowCount: rows.length,
865
+ hasMore: page.hasMore,
866
+ onPageChange: page.setPage,
867
+ onPageSizeChange: page.setPageSize
868
+ }),
711
869
  /*#__PURE__*/ _jsx(Typography, {
712
870
  variant: "caption",
713
871
  color: "text.secondary",
714
872
  children: 'These figures measure each campaign’s mail. What a campaign ' + 'caused — the form submissions, leads, contacts and bookings ' + 'credited to it — and the pages it sent people to are on the ' + 'campaign’s own page.'
715
873
  }),
716
- sendsTruncated || campaignsTruncated ? /*#__PURE__*/ _jsx(Alert, {
874
+ rollupTruncated ? /*#__PURE__*/ _jsx(Alert, {
717
875
  severity: "info",
718
- children: `Showing the most recent ${CONTAINER_CEILING} campaigns and ` + `${CAMPAIGN_CEILING} sends. ${hostId ? 'This site' : 'This organization'} has more — the ones ` + 'listed are not necessarily the most recent, because a send ' + 'carries no date field that every writer stamps.'
876
+ children: `The campaigns on this page hold more than ${ROLLUP_CEILING} ` + 'emails between them, so their figures count the first ' + `${ROLLUP_CEILING} read.`
719
877
  }) : null
720
878
  ]
721
879
  }),