@aglyn/plugins-marketing 1.0.0-beta.199 → 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
@@ -20,34 +20,32 @@ import { pluginDocsHelp } from "@aglyn/aglyn";
20
20
  import { mdiBullhornOutline, mdiContentCopy, mdiDeleteOutline, mdiEyeOutline, mdiPaletteOutline } from "@aglyn/shared-data-mdi";
21
21
  import { AppLink, CardDisplay, MdiIcon, useConfirmationContext } from "@aglyn/shared-ui-jsx";
22
22
  import ListFilterChips from "@aglyn/shared-ui-jsx/components/list-filter-chips.component";
23
+ import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
24
+ import ListQueryNotices, { listQueryRefusals } from "@aglyn/shared-ui-jsx/components/list-query-notices.component";
23
25
  import { ListRowActions, ListTable, listActionsColumn } from "@aglyn/shared-ui-jsx/components/list-table.component";
24
- import { inMemoryListField } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
25
- import { useListRowsFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-rows-filter";
26
+ import { hiddenFilterVisibility } from "@aglyn/shared-ui-jsx/const/list-filter";
27
+ import { listFilterGridColumns } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
28
+ import { useListGridFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-grid-filter";
26
29
  import { TABLE_ROW_HEIGHT } from "@aglyn/shared-ui-jsx/const/table-pagination";
27
30
  import { CreateArtifactDrawer } from "@aglyn/shared-ui-jsx-forms";
28
31
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
29
- import { ceilingedWindow, collectionCeiling } from "@aglyn/tenant-feature-instance/hooks/host-collection-queries";
32
+ import { collectionCeiling } from "@aglyn/tenant-feature-instance/hooks/host-collection-queries";
33
+ import { useListQuery } from "@aglyn/tenant-feature-instance/hooks/use-list-query";
30
34
  import { DUPLICATE_MENU_LABEL, useDuplicateResource, useFirestore, useFirestoreCollection } from "@aglyn/tenant-feature-instance";
31
- import { Alert, Button, Chip, Stack, Typography } from "@mui/material";
35
+ import { Button, Chip, Stack, Typography } from "@mui/material";
32
36
  import { useRouter } from "next/navigation";
33
37
  import { useCallback, useMemo, useState } from "react";
34
38
  import { campaignPlacedOnHost, campaignSendDisplay, CAMPAIGN_SEND_CONTAINER_FIELD } from "@aglyn/shared-ui-email-campaigns/model/campaign-container";
35
39
  import { emailListTimeMs, emailSendTimeMs } from "@aglyn/shared-ui-email-campaigns/model/email-record";
36
40
  import { useCampaignManageApi, useCampaignSendApi } from "./use-campaign-send-api.js";
37
- import { EMAIL_STATE_FILTER_FIELD, EMAIL_STATE_OPTIONS, EMAIL_SUBJECT_FILTER_FIELD, emailFilterValues } from "./email-send-filter.js";
41
+ import { CAMPAIGN_SEND_STATUS_OPTIONS, SINGLE_SEND_FILTER_VALUE, campaignEmailsListQuery, campaignSendsScope, emailCampaignQueryClauses } from "../model/campaign-list-query.js";
38
42
  import { useMarketingHubPath } from "./use-marketing-hub-path.js";
39
- import { campaignContainersQuery, campaignSendsQuery } from "./campaign-queries.js";
43
+ import { campaignContainersQuery, campaignSendsCollection } from "./campaign-queries.js";
40
44
  import { orgSiteHubPath, orgSiteName, orgSiteOptions, useMarketingOrgId, useMarketingOrgMount } from "./marketing-org-mount.js";
41
- /** How many messages one read of this list covers. */ const EMAIL_CEILING = 30;
42
45
  /**
43
46
  * How many campaigns the Campaign filter and the create drawer offer — the
44
- * same read serves both.
47
+ * same read serves both. A picker's options, not a list.
45
48
  */ const CONTAINER_CEILING = 50;
46
- /**
47
- * The Campaign filter's value for an email filed under no campaign. An empty
48
- * value reads as "no value" to the filter, so a clause on it would never
49
- * apply; a sentinel is a value like any campaign id.
50
- */ const SINGLE_SEND_FILTER_VALUE = '__single__';
51
49
  /**
52
50
  * Why discard is refused, keyed by the state that refuses it.
53
51
  *
@@ -78,28 +76,17 @@ import { orgSiteHubPath, orgSiteName, orgSiteOptions, useMarketingOrgId, useMark
78
76
  stopped: 'warning'
79
77
  };
80
78
  /*
81
- * What the messages grid's Filters panel offers (AGL-3317). The card reads
82
- * every message under its ceiling, so the panel and the search answer over
83
- * all of them. State is what the email is DOING (`campaignSendDisplay`),
84
- * not the status it stores.
85
- */ const EMAIL_FILTER_FIELDS = [
86
- EMAIL_SUBJECT_FILTER_FIELD,
87
- EMAIL_STATE_FILTER_FIELD,
88
- // Matched on a derived key, so the row's own container field stays what
89
- // was stored for the row menu that reads it.
90
- inMemoryListField('emailCampaignId', 'select', 'campaignFilterKey'),
91
- inMemoryListField('site', 'text', 'siteName')
92
- ];
93
- const EMAIL_FILTER_HEADERS = {
79
+ * What the messages grid's Filters panel offers, each clause and the search
80
+ * on the list's Firestore query (AGL-3321, `campaignEmailsListQuery`). The
81
+ * State column is what the email is DOING (`campaignSendDisplay`), which no
82
+ * query can ask, so the panel offers the Status it STORES instead.
83
+ */ const EMAIL_FILTER_HEADERS = {
94
84
  subject: 'Subject',
95
- state: 'State',
85
+ status: 'Status',
96
86
  emailCampaignId: 'Campaign',
97
- site: 'Site'
87
+ site: 'Site',
88
+ createdAtMs: 'Created'
98
89
  };
99
- /** What the quick search reads on a message row. */ const EMAIL_SEARCH_FIELDS = [
100
- 'subjectText',
101
- 'siteName'
102
- ];
103
90
  const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
104
91
  anchor: '#opens--clicks',
105
92
  excerpt: 'Every message this site has sent or has scheduled, each with its own ' + 'report: what was delivered, who opened it, and which links they followed.'
@@ -112,23 +99,19 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
112
99
  * 14th, and to whom" — and they were previously the same list because a
113
100
  * campaign document WAS a single send.
114
101
  *
115
- * ## Ordered in the browser, deliberately
116
- *
117
- * No SEND date is on every message: a sent one carries `sentAt` and a
118
- * scheduled one carries `sendAtMs`, written by two different branches of the
119
- * send path, and a draft carries neither — so `orderBy` on either would not
120
- * mis-sort this list, it would DROP half of it. `collectionCeiling` reads a
121
- * bounded window in document-id order and probes one past the ceiling, so the
122
- * rows are sorted here and the reader is told when there are more.
102
+ * ## Asked of Firestore, a page at a time
123
103
  *
124
- * Every writer stamps a `createdAtMs`, which is the field this list could be
125
- * ordered on in Firestore. Moving it there is a query change with the same
126
- * hazard as the two above — `orderBy` drops a document missing the field —
127
- * so it needs the corpus proven to carry one, not just the writers.
104
+ * No SEND date is on every message — a sent one carries `sentAt`, a
105
+ * scheduled one `sendAtMs`, a draft neither — so the list is ordered on
106
+ * `createdAtMs`, the one date every writer stamps when it mints a record
107
+ * (and `backfill-campaign-list-fields.mjs` stamped on the ones before).
108
+ * Newest first, paged by the query itself.
128
109
  *
129
- * The page is therefore a SLICE of a window this card already holds, not a
130
- * query: paging an id-ordered walk and re-sorting each page by date would run
131
- * in one order within a page and another across them.
110
+ * The Filters panel and the search box are the grid's; every clause and the
111
+ * search word go onto that ONE query (AGL-3321, `campaignEmailsListQuery`),
112
+ * so each page is a page of matches — an email that matches on page four is
113
+ * found — and a combination one query cannot hold is refused by name above
114
+ * the table, never answered over the rows that happen to be loaded.
132
115
  *
133
116
  * ## Under a site, and over the organization
134
117
  *
@@ -147,32 +130,34 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
147
130
  const siteMarketingHub = useMarketingHubPath();
148
131
  const marketingHub = orgMount ? orgMount.basePath : siteMarketingHub;
149
132
  const router = useRouter();
150
- const { data: emailDocs } = useFirestoreCollection(()=>{
151
- const sends = campaignSendsQuery(firestore, orgId, hostId);
152
- return sends ? collectionCeiling(sends, EMAIL_CEILING) : null;
153
- }, [
154
- firestore,
155
- orgId,
156
- hostId
157
- ], {
133
+ /*
134
+ * The Filters panel and the search, bound to the grid. Held here rather
135
+ * than in a saved view: this list keeps no views.
136
+ */ const declaration = campaignEmailsListQuery(!hostId);
137
+ const gridFilter = useListGridFilter({
138
+ selectFields: [
139
+ 'status',
140
+ 'emailCampaignId',
141
+ 'site'
142
+ ]
143
+ });
144
+ const emailPage = useListQuery({
145
+ collection: campaignSendsCollection(firestore, orgId),
146
+ declaration,
147
+ request: {
148
+ clauses: emailCampaignQueryClauses(gridFilter.clauses),
149
+ search: gridFilter.searchWords,
150
+ base: campaignSendsScope(hostId)
151
+ },
152
+ deps: [
153
+ firestore,
154
+ orgId,
155
+ hostId
156
+ ],
158
157
  idField: '$id'
159
158
  });
160
- const { rows: readEmails, truncated } = ceilingedWindow(emailDocs, EMAIL_CEILING);
161
- /*
162
- * Newest first on the time each message SITS at — its send time where it
163
- * has one, its creation where it does not.
164
- *
165
- * A draft has neither `sentAt` nor `sendAtMs`, so sorting on the send time
166
- * alone gave every draft the key 0 and filed the email a merchant is in the
167
- * middle of writing at the very bottom of the list, behind whatever paging
168
- * it has. `emailListTimeMs` is the same ordering with that one gap closed;
169
- * a SENT message still orders by when it went out, never by when it was
170
- * drafted.
171
- */ const emails = useMemo(()=>[
172
- ...readEmails
173
- ].sort((a, b)=>emailListTimeMs(b) - emailListTimeMs(a)), [
174
- readEmails
175
- ]);
159
+ const emails = emailPage.rows;
160
+ const filtering = gridFilter.clauses.length > 0 || gridFilter.searchWords.some((word)=>word.trim());
176
161
  /*
177
162
  * A message's own pages, beneath the Emails page this list is on — the
178
163
  * site's or the organization's. A template is a site's design, so over the
@@ -183,11 +168,11 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
183
168
  /** The site a row is sent as, which every action on it must name. */ const rowHostId = useCallback((email)=>hostId != null ? hostId : (email == null ? void 0 : email.hostId) ? String(email.hostId) : null, [
184
169
  hostId
185
170
  ]);
186
- const hostOfEmail = useMemo(()=>new Map(readEmails.map((email)=>[
171
+ const hostOfEmail = useMemo(()=>new Map(emails.map((email)=>[
187
172
  String(email.$id),
188
173
  (email == null ? void 0 : email.hostId) ? String(email.hostId) : null
189
174
  ])), [
190
- readEmails
175
+ emails
191
176
  ]);
192
177
  const { confirm } = useConfirmationContext();
193
178
  const { enqueueSnackbar } = useSnackbar();
@@ -406,18 +391,8 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
406
391
  })).sort((a, b)=>a.label.localeCompare(b.label)), [
407
392
  campaignDocs
408
393
  ]);
409
- const filterRows = useMemo(()=>emails.map((email)=>_extends({}, email, emailFilterValues(email), {
410
- campaignFilterKey: email.emailCampaignId ? String(email.emailCampaignId) : SINGLE_SEND_FILTER_VALUE,
411
- siteName: orgMount && (email == null ? void 0 : email.hostId) ? orgSiteName(orgMount, email.hostId) : ''
412
- })), [
413
- emails,
414
- orgMount
415
- ]);
416
- const filterFields = useMemo(()=>orgMount ? EMAIL_FILTER_FIELDS : EMAIL_FILTER_FIELDS.filter((field)=>field.column !== 'site'), [
417
- orgMount
418
- ]);
419
- const filterOptions = useMemo(()=>({
420
- state: EMAIL_STATE_OPTIONS,
394
+ const filterOptions = useMemo(()=>_extends({
395
+ status: CAMPAIGN_SEND_STATUS_OPTIONS,
421
396
  emailCampaignId: [
422
397
  {
423
398
  value: SINGLE_SEND_FILTER_VALUE,
@@ -425,16 +400,13 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
425
400
  },
426
401
  ...campaignOptions
427
402
  ]
403
+ }, hostId ? {} : {
404
+ site: orgSites
428
405
  }), [
429
- campaignOptions
406
+ campaignOptions,
407
+ hostId,
408
+ orgSites
430
409
  ]);
431
- const listFilter = useListRowsFilter({
432
- rows: filterRows,
433
- fields: filterFields,
434
- options: filterOptions,
435
- headers: EMAIL_FILTER_HEADERS,
436
- search: EMAIL_SEARCH_FIELDS
437
- });
438
410
  /*==========================================
439
411
  * CREATE, THEN GO TO THE EMAIL'S OWN PAGE.
440
412
  *
@@ -520,9 +492,9 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
520
492
  router
521
493
  ]);
522
494
  /*
523
- * One row per message, in the order above; the whole window is in hand, so
524
- * the grid pages it. The subject is a link AND the row opens the report: a
525
- * click handler cannot be middle-clicked or opened in a new tab.
495
+ * One row per message, in the query's order. The subject is a link AND the
496
+ * row opens the report: a click handler cannot be middle-clicked or opened
497
+ * in a new tab.
526
498
  */ const columns = [
527
499
  {
528
500
  field: 'subject',
@@ -573,8 +545,6 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
573
545
  field: 'when',
574
546
  headerName: 'When',
575
547
  width: 190,
576
- // Sorted on the time the list is ordered by, so a draft sorts by its
577
- // creation rather than as the oldest thing on the page.
578
548
  valueGetter: (_value, row)=>emailListTimeMs(row),
579
549
  renderCell: ({ row })=>{
580
550
  const at = emailSendTimeMs(row);
@@ -615,6 +585,8 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
615
585
  width: 72
616
586
  })
617
587
  ];
588
+ const gridColumns = listFilterGridColumns(columns, declaration.fields, filterOptions, EMAIL_FILTER_HEADERS);
589
+ /** Nothing stored at all — not a filter that matched nothing. */ const none = emailPage.status !== 'loading' && emails.length === 0 && !filtering && emailPage.page === 0;
618
590
  return /*#__PURE__*/ _jsxs(CardDisplay, {
619
591
  header: 'Messages',
620
592
  help: emailsDocsHelp,
@@ -630,52 +602,77 @@ const emailsDocsHelp = pluginDocsHelp('emailCampaigns', {
630
602
  contentGutterY: true,
631
603
  children: [
632
604
  duplicate.dialog,
633
- /*#__PURE__*/ _jsxs(Stack, {
605
+ /*#__PURE__*/ _jsx(Stack, {
634
606
  spacing: 2,
635
- children: [
636
- emails.length === 0 ? /*#__PURE__*/ _jsxs(Stack, {
637
- spacing: 2,
638
- sx: {
639
- alignItems: 'flex-start'
640
- },
641
- children: [
642
- /*#__PURE__*/ _jsx(Typography, {
643
- variant: "body2",
644
- color: "text.secondary",
645
- children: 'Nothing has been sent or scheduled yet. Write one here, or ' + 'from a campaign, and it appears in this list with its own ' + 'report.'
607
+ children: none ? /*#__PURE__*/ _jsxs(Stack, {
608
+ spacing: 2,
609
+ sx: {
610
+ alignItems: 'flex-start'
611
+ },
612
+ children: [
613
+ /*#__PURE__*/ _jsx(Typography, {
614
+ variant: "body2",
615
+ color: "text.secondary",
616
+ children: 'Nothing has been sent or scheduled yet. Write one here, or ' + 'from a campaign, and it appears in this list with its own ' + 'report.'
617
+ }),
618
+ /*#__PURE__*/ _jsx(Button, {
619
+ variant: "contained",
620
+ onClick: ()=>setCreateOpen(true),
621
+ children: 'New email'
622
+ })
623
+ ]
624
+ }) : /*#__PURE__*/ _jsxs(_Fragment, {
625
+ children: [
626
+ /*#__PURE__*/ _jsx(ListFilterChips, {
627
+ fields: declaration.fields,
628
+ headers: EMAIL_FILTER_HEADERS,
629
+ clauses: gridFilter.clauses,
630
+ onChange: gridFilter.setClauses,
631
+ options: filterOptions
632
+ }),
633
+ /*#__PURE__*/ _jsx(ListQueryNotices, {
634
+ refused: listQueryRefusals(emailPage.plan.refused, {
635
+ fields: declaration.fields,
636
+ headers: EMAIL_FILTER_HEADERS,
637
+ options: filterOptions
646
638
  }),
647
- /*#__PURE__*/ _jsx(Button, {
648
- variant: "contained",
649
- onClick: ()=>setCreateOpen(true),
650
- children: 'New email'
651
- })
652
- ]
653
- }) : /*#__PURE__*/ _jsxs(_Fragment, {
654
- children: [
655
- /*#__PURE__*/ _jsx(ListFilterChips, _extends({}, listFilter.chipsProps)),
656
- /*#__PURE__*/ _jsx(ListTable, _extends({
657
- "aria-label": "Messages",
658
- rows: listFilter.rows,
659
- columns: listFilter.filterColumns(columns),
660
- rowHeight: TABLE_ROW_HEIGHT,
661
- onOpen: (_id, row)=>router.push(emailHref(row))
662
- }, listFilter.gridProps, {
663
- initialState: {
664
- columns: {
665
- columnVisibilityModel: {
666
- emailCampaignId: false
667
- }
668
- }
669
- },
670
- noRowsLabel: "No messages match these filters"
671
- }))
672
- ]
673
- }),
674
- truncated ? /*#__PURE__*/ _jsx(Alert, {
675
- severity: "info",
676
- children: `Showing ${EMAIL_CEILING} messages. ${hostId ? 'This site' : 'This organization'} has sent or ` + 'scheduled more than that, and the rest are not in this list.'
677
- }) : null
678
- ]
639
+ notices: emailPage.plan.notices
640
+ }),
641
+ /*#__PURE__*/ _jsx(ListTable, {
642
+ "aria-label": "Messages",
643
+ rows: emails,
644
+ columns: gridColumns,
645
+ rowHeight: TABLE_ROW_HEIGHT,
646
+ onOpen: (_id, row)=>router.push(emailHref(row)),
647
+ loading: emailPage.status === 'loading',
648
+ /*
649
+ * The panel and the search are the grid's; every clause and
650
+ * the search word are on the list's query (AGL-3321), and the
651
+ * grid neither filters nor sorts the page it is handed: the
652
+ * query's one order, newest first, is the list's.
653
+ */ filterMode: "server",
654
+ filterModel: gridFilter.filterModel,
655
+ onFilterModelChange: gridFilter.onFilterModelChange,
656
+ quickFilter: true,
657
+ disableColumnSorting: true,
658
+ hideFooter: true,
659
+ initialState: {
660
+ columns: {
661
+ columnVisibilityModel: hiddenFilterVisibility(declaration.fields, columns.map((column)=>column.field))
662
+ }
663
+ },
664
+ noRowsLabel: "No messages match these filters"
665
+ }),
666
+ /*#__PURE__*/ _jsx(ListPagination, {
667
+ page: emailPage.page,
668
+ pageSize: emailPage.pageSize,
669
+ rowCount: emails.length,
670
+ hasMore: emailPage.hasMore,
671
+ onPageChange: emailPage.setPage,
672
+ onPageSizeChange: emailPage.setPageSize
673
+ })
674
+ ]
675
+ })
679
676
  }),
680
677
  /*#__PURE__*/ _jsx(CreateArtifactDrawer, {
681
678
  open: createOpen,