@aglyn/plugins-email 1.0.0-beta.188 → 1.0.0-beta.190
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.
- package/package.json +17 -17
- package/src/lib/components/email-screens-card.js +61 -9
- package/src/lib/components/email-screens-card.js.map +1 -1
- package/src/lib/components/list-members-panel.js +101 -51
- package/src/lib/components/list-members-panel.js.map +1 -1
- package/src/lib/components/lists-card.js +74 -15
- package/src/lib/components/lists-card.js.map +1 -1
- package/src/lib/components/org-email-templates-card.js +79 -15
- package/src/lib/components/org-email-templates-card.js.map +1 -1
- package/src/lib/components/suppressions-card.js +75 -18
- package/src/lib/components/suppressions-card.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/plugins-email",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.190",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,22 +25,22 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-data-types": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-ui-email-campaigns": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
34
|
-
"@aglyn/shared-ui-jsx-forms": "1.0.0-beta.
|
|
35
|
-
"@aglyn/shared-ui-next": "1.0.0-beta.
|
|
36
|
-
"@aglyn/shared-ui-snackstack": "1.0.0-beta.
|
|
37
|
-
"@aglyn/shared-ui-theme": "1.0.0-beta.
|
|
38
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
39
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
40
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
41
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
42
|
-
"@aglyn/tenant-data-admin": "1.0.0-beta.
|
|
43
|
-
"@aglyn/tenant-feature-instance": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.190",
|
|
29
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.190",
|
|
30
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.190",
|
|
31
|
+
"@aglyn/shared-data-types": "1.0.0-beta.190",
|
|
32
|
+
"@aglyn/shared-ui-email-campaigns": "1.0.0-beta.190",
|
|
33
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.190",
|
|
34
|
+
"@aglyn/shared-ui-jsx-forms": "1.0.0-beta.190",
|
|
35
|
+
"@aglyn/shared-ui-next": "1.0.0-beta.190",
|
|
36
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.190",
|
|
37
|
+
"@aglyn/shared-ui-theme": "1.0.0-beta.190",
|
|
38
|
+
"@aglyn/shared-util-email": "1.0.0-beta.190",
|
|
39
|
+
"@aglyn/shared-util-http": "1.0.0-beta.190",
|
|
40
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.190",
|
|
41
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.190",
|
|
42
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.190",
|
|
43
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.190",
|
|
44
44
|
"@swc/helpers": "0.5.23"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
@@ -14,12 +14,16 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ 'use client';
|
|
17
|
-
import {
|
|
17
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
18
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
18
19
|
import { buildRoute, pluginDocsHelp, Route } from "@aglyn/aglyn";
|
|
19
20
|
import { mdiDeleteOutline, mdiContentCopy, mdiEyeOutline, mdiPencilOutline } from "@aglyn/shared-data-mdi";
|
|
20
21
|
import { AppLink, CardDisplay, MdiIcon, useConfirmationContext } from "@aglyn/shared-ui-jsx";
|
|
22
|
+
import ListFilterChips from "@aglyn/shared-ui-jsx/components/list-filter-chips.component";
|
|
21
23
|
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";
|
|
22
25
|
import { TABLE_ROW_HEIGHT } from "@aglyn/shared-ui-jsx/const/table-pagination";
|
|
26
|
+
import { useListRowsFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-rows-filter";
|
|
23
27
|
import { useSnackbar } from "@aglyn/shared-ui-snackstack";
|
|
24
28
|
import { ceilingedWindow, collectionCeiling } from "@aglyn/tenant-feature-instance/hooks/host-collection-queries";
|
|
25
29
|
import { DUPLICATE_MENU_LABEL, useConsoleHostRoute, useDuplicateResource, useFirestore, useFirestoreCollection, useHostResourceApi, useHostVersionApi } from "@aglyn/tenant-feature-instance";
|
|
@@ -30,6 +34,34 @@ import { useMemo } from "react";
|
|
|
30
34
|
import { templateProvenance } from "../model/template-provenance.js";
|
|
31
35
|
import { createEmailScreen } from "../utils/create-email-screen.js";
|
|
32
36
|
/** How many of the site's screens one read of this list covers. */ const TEMPLATE_CEILING = 200;
|
|
37
|
+
/*
|
|
38
|
+
* What the templates grid's Filters panel offers (AGL-3317). The card holds
|
|
39
|
+
* the templates its capped window read, so it answers the panel over all of
|
|
40
|
+
* them; the notice under the table says when the cap truncated the window.
|
|
41
|
+
* Origin reads `originKey`, the provenance the column draws.
|
|
42
|
+
*/ const TEMPLATE_FILTER_FIELDS = [
|
|
43
|
+
inMemoryListField('displayName', 'text', 'templateName'),
|
|
44
|
+
inMemoryListField('origin', 'select', 'originKey')
|
|
45
|
+
];
|
|
46
|
+
const TEMPLATE_FILTER_HEADERS = {
|
|
47
|
+
displayName: 'Template',
|
|
48
|
+
origin: 'Origin'
|
|
49
|
+
};
|
|
50
|
+
const TEMPLATE_FILTER_OPTIONS = {
|
|
51
|
+
origin: [
|
|
52
|
+
{
|
|
53
|
+
value: 'local',
|
|
54
|
+
label: 'Yours'
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
value: 'installed',
|
|
58
|
+
label: 'Installed'
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
};
|
|
62
|
+
const TEMPLATE_SEARCH_FIELDS = [
|
|
63
|
+
'templateName'
|
|
64
|
+
];
|
|
33
65
|
// The besigner route is `/[orgSlug]/hosts/[host]/screens/[screenId]/
|
|
34
66
|
// versions/[versionId]/besigner`. This built `/{hostDocId}/screens/…`, the
|
|
35
67
|
// pre-AGL-621/622 shape — so every "Edit"/"Design" jump out of the Emails
|
|
@@ -102,9 +134,22 @@ const besignerHref = (orgSlug, host, screenId, versionId)=>buildRoute(Route.SCRE
|
|
|
102
134
|
].filter((screen)=>!screen.deletedAt && screen.kind === 'email').sort((a, b)=>{
|
|
103
135
|
var _a_displayName, _b_displayName;
|
|
104
136
|
return String((_a_displayName = a.displayName) != null ? _a_displayName : '').localeCompare(String((_b_displayName = b.displayName) != null ? _b_displayName : ''));
|
|
137
|
+
}).map((screen)=>{
|
|
138
|
+
var _screen_displayName;
|
|
139
|
+
return _extends({}, screen, {
|
|
140
|
+
templateName: String((_screen_displayName = screen.displayName) != null ? _screen_displayName : 'Untitled template'),
|
|
141
|
+
originKey: templateProvenance(screen).origin === 'installed' ? 'installed' : 'local'
|
|
142
|
+
});
|
|
105
143
|
}), [
|
|
106
144
|
readScreens
|
|
107
145
|
]);
|
|
146
|
+
const templateFilter = useListRowsFilter({
|
|
147
|
+
rows: emailScreens,
|
|
148
|
+
fields: TEMPLATE_FILTER_FIELDS,
|
|
149
|
+
options: TEMPLATE_FILTER_OPTIONS,
|
|
150
|
+
headers: TEMPLATE_FILTER_HEADERS,
|
|
151
|
+
search: TEMPLATE_SEARCH_FIELDS
|
|
152
|
+
});
|
|
108
153
|
const handleCreate = async ()=>{
|
|
109
154
|
try {
|
|
110
155
|
const { screenId, versionId } = await createEmailScreen(hostId, createHostResource, createHostVersion);
|
|
@@ -214,8 +259,8 @@ const besignerHref = (orgSlug, host, screenId, versionId)=>buildRoute(Route.SCRE
|
|
|
214
259
|
*/ field: 'origin',
|
|
215
260
|
headerName: 'Origin',
|
|
216
261
|
width: 140,
|
|
217
|
-
valueGetter: (_value, row)=>
|
|
218
|
-
renderCell: ({ value })=>value === '
|
|
262
|
+
valueGetter: (_value, row)=>row.originKey,
|
|
263
|
+
renderCell: ({ value })=>value === 'installed' ? /*#__PURE__*/ _jsx(Chip, {
|
|
219
264
|
size: "small",
|
|
220
265
|
label: "Installed"
|
|
221
266
|
}) : /*#__PURE__*/ _jsx(Typography, {
|
|
@@ -255,12 +300,19 @@ const besignerHref = (orgSlug, host, screenId, versionId)=>buildRoute(Route.SCRE
|
|
|
255
300
|
variant: "body2",
|
|
256
301
|
color: "text.secondary",
|
|
257
302
|
children: 'Design a reusable email here, then send it from a campaign. A ' + 'new template opens in the besigner with email-safe components ' + 'only.'
|
|
258
|
-
}) : /*#__PURE__*/
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
303
|
+
}) : /*#__PURE__*/ _jsxs(_Fragment, {
|
|
304
|
+
children: [
|
|
305
|
+
/*#__PURE__*/ _jsx(ListFilterChips, _extends({}, templateFilter.chipsProps)),
|
|
306
|
+
/*#__PURE__*/ _jsx(ListTable, _extends({
|
|
307
|
+
"aria-label": "Email templates",
|
|
308
|
+
rows: templateFilter.rows,
|
|
309
|
+
columns: templateFilter.filterColumns(columns),
|
|
310
|
+
rowHeight: TABLE_ROW_HEIGHT,
|
|
311
|
+
onOpen: (_id, row)=>router.push(templateHref(row))
|
|
312
|
+
}, templateFilter.gridProps, {
|
|
313
|
+
noRowsLabel: "No templates match these filters"
|
|
314
|
+
}))
|
|
315
|
+
]
|
|
264
316
|
}),
|
|
265
317
|
truncated ? /*#__PURE__*/ _jsx(Alert, {
|
|
266
318
|
severity: "info",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/email-screens-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 { buildRoute, pluginDocsHelp, Route } from '@aglyn/aglyn'\nimport {\n mdiDeleteOutline,\n mdiContentCopy,\n mdiEyeOutline,\n mdiPencilOutline,\n} from '@aglyn/shared-data-mdi'\nimport {\n AppLink,\n CardDisplay,\n MdiIcon,\n useConfirmationContext,\n} from '@aglyn/shared-ui-jsx'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n ceilingedWindow,\n collectionCeiling,\n} from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport {\n DUPLICATE_MENU_LABEL,\n useConsoleHostRoute,\n useDuplicateResource,\n useFirestore,\n useFirestoreCollection,\n useHostResourceApi,\n useHostVersionApi,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Button,\n Chip,\n Stack,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport { collection, doc, Timestamp, updateDoc } from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useMemo } from 'react'\nimport { templateProvenance } from '../model/template-provenance'\nimport { createEmailScreen } from '../utils/create-email-screen'\n\n/** How many of the site's screens one read of this list covers. */\nconst TEMPLATE_CEILING = 200\n\n// The besigner route is `/[orgSlug]/hosts/[host]/screens/[screenId]/\n// versions/[versionId]/besigner`. This built `/{hostDocId}/screens/…`, the\n// pre-AGL-621/622 shape — so every \"Edit\"/\"Design\" jump out of the Emails\n// page landed on a 404, including the one right after creating a new email\n// (AGL-685). Takes the resolved org slug + subdomain, not a host doc id.\nconst besignerHref = (\n orgSlug: string,\n host: string,\n screenId: string,\n versionId: string,\n) =>\n buildRoute(Route.SCREEN_BESIGNER, { orgSlug, host, screenId, versionId })\n\n/**\n * THE TEMPLATES: reusable besigner documents an email is built from.\n *\n * A template is a screen document with `kind: 'email'`, kept out of the main\n * Screens list and opened in the besigner with only email-safe components on\n * offer. It is not itself a message — a message is what a campaign sends, and\n * one template can be behind many of them, which is why the row leads to the\n * template's own page rather than straight into the editor.\n *\n * A template is not necessarily this org's. One installed from a marketplace\n * listing appears here beside the locally authored ones, carries its\n * publisher's provenance on the same document, and opens the same detail\n * page; the chip beside its name is what distinguishes them.\n *\n * ## A table, on the surface's own row grammar\n *\n * The row opens the template, its name is ALSO a real link so it can be\n * middle-clicked and copied, and editing and deleting live behind the shared\n * overflow menu rather than as text buttons in the row — the same grammar the\n * audiences table reads by. Delete in particular: it sat inline, one mis-click\n * from the name beside it.\n *\n * ## Why the read is a CEILING with a probe\n *\n * `screens` holds every kind of screen the site has and the email ones are\n * picked out here, so the cap cannot be a page: a page of the collection is\n * not a page of this list. `collectionCeiling` orders on the document name,\n * which is the one ordering that cannot drop a screen — `orderBy('displayName')`\n * matches only documents that HAVE the field, so a screen created without a\n * name would vanish rather than sort oddly. It reads one past the ceiling so\n * the card can SAY when the site holds more than it drew, and the footer under\n * the table pages the window the card already has.\n */\nexport function EmailScreensCard(props: {\n hostId: string\n /** The emails hub URL, which every template route hangs beneath. */\n basePath: string\n}) {\n const { hostId, basePath } = props\n const { orgSlug, subdomain } = useConsoleHostRoute(hostId)\n const firestore = useFirestore()\n const createHostResource = useHostResourceApi()\n const createHostVersion = useHostVersionApi()\n const router = useRouter()\n const { enqueueSnackbar } = useSnackbar()\n // Duplicate (AGL-2936): the copy is a design of its own, sent by nothing\n // until a campaign picks it.\n const duplicate = useDuplicateResource({\n hostId,\n onDuplicated: (_kind, copy) =>\n enqueueSnackbar(`Duplicated as “${copy.name}”`, { variant: 'success' }),\n })\n const { confirm } = useConfirmationContext()\n\n const { data: screenDocs } = useFirestoreCollection<any>(\n () =>\n collectionCeiling(\n collection(firestore, 'hosts', hostId, 'screens'),\n TEMPLATE_CEILING,\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n const { rows: readScreens, truncated } = ceilingedWindow<any>(\n screenDocs,\n TEMPLATE_CEILING,\n )\n const emailScreens = useMemo(\n () =>\n [...readScreens]\n .filter((screen: any) => !screen.deletedAt && screen.kind === 'email')\n .sort((a: any, b: any) =>\n String(a.displayName ?? '').localeCompare(\n String(b.displayName ?? ''),\n ),\n ),\n [readScreens],\n )\n\n const handleCreate = async () => {\n try {\n const { screenId, versionId } = await createEmailScreen(\n hostId,\n createHostResource,\n createHostVersion,\n )\n if (orgSlug && subdomain) {\n void router.push(besignerHref(orgSlug, subdomain, screenId, versionId))\n }\n } catch (error: any) {\n console.error(error)\n enqueueSnackbar(error?.message ?? 'Creating the template failed', {\n variant: 'error',\n })\n }\n }\n\n const handleDelete = async (screen: any) => {\n const confirmed = await confirm({\n title: 'Delete this template?',\n description:\n `\"${screen.displayName ?? 'Untitled template'}\" will be removed. ` +\n 'Emails already sent from it keep their reports.',\n confirmationText: 'Delete',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n await updateDoc(doc(firestore, 'hosts', hostId, 'screens', screen.$id), {\n deletedAt: Timestamp.now(),\n })\n }\n\n const templateHref = (screen: any) => `${basePath}/templates/${screen.$id}`\n\n const templateName = (screen: any) =>\n String(screen.displayName ?? 'Untitled template')\n\n const rowActions = (screen: any): RowActionsMenuItem[] => [\n {\n key: 'details',\n label: 'Open details',\n icon: <MdiIcon path={mdiEyeOutline.path} size={0.8} />,\n href: templateHref(screen),\n },\n {\n key: 'besigner',\n label: 'Edit in besigner',\n icon: <MdiIcon path={mdiPencilOutline.path} size={0.8} />,\n /*\n The editor's URL needs the resolved org slug and subdomain. Until the\n host route settles both are empty and a link built from them lands on\n `/null/hosts/null`, so the item is shown DISABLED with the reason\n rather than pointing at a 404.\n */\n href:\n orgSlug && subdomain\n ? besignerHref(orgSlug, subdomain, screen.$id, screen.versionId)\n : undefined,\n disabled: !orgSlug || !subdomain,\n disabledReason: 'This site’s console URL has not resolved yet',\n },\n {\n key: 'duplicate',\n label: DUPLICATE_MENU_LABEL,\n icon: <MdiIcon path={mdiContentCopy.path} size={0.8} />,\n onClick: () =>\n duplicate.request('emailDesign', {\n id: screen.$id,\n name: templateName(screen),\n }),\n },\n {\n key: 'delete',\n label: 'Delete',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleDelete(screen),\n },\n ]\n\n /*\n * The templates are a window this card already holds, so the grid pages\n * it. The name is a link AND the row opens the template.\n */\n const columns: GridColDef[] = [\n {\n field: 'displayName',\n headerName: 'Template',\n flex: 1,\n minWidth: 220,\n valueGetter: (_value, row) => templateName(row),\n renderCell: ({ row, value }) => (\n <AppLink\n href={templateHref(row)}\n // The row's own handler would fire too and push the same route\n // twice — one history entry per back press.\n onClick={(event: { stopPropagation: () => void }) =>\n event.stopPropagation()\n }\n >\n {value}\n </AppLink>\n ),\n },\n {\n /*\n WHOSE template this is, where the reader is choosing between them. An\n installed one is versioned by its publisher and can be withdrawn, which\n is not a property a name can carry.\n */\n field: 'origin',\n headerName: 'Origin',\n width: 140,\n valueGetter: (_value, row) =>\n templateProvenance(row).origin === 'installed' ? 'Installed' : 'Yours',\n renderCell: ({ value }) =>\n value === 'Installed' ? (\n <Chip size=\"small\" label=\"Installed\" />\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Yours'}\n </Typography>\n ),\n },\n listActionsColumn(\n (row) => (\n <ListRowActions label={templateName(row)} items={rowActions(row)} />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <CardDisplay\n header={'Templates'}\n help={pluginDocsHelp('designedEmails', { anchor: '#create-a-template' })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Button\n size=\"small\"\n variant=\"contained\"\n onClick={() => void handleCreate()}\n >\n {'New template'}\n </Button>\n ),\n }}\n >\n {duplicate.dialog}\n <Stack spacing={1.5}>\n {emailScreens.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Design a reusable email here, then send it from a campaign. A ' +\n 'new template opens in the besigner with email-safe components ' +\n 'only.'}\n </Typography>\n ) : (\n <ListTable\n aria-label=\"Email templates\"\n rows={emailScreens}\n columns={columns}\n rowHeight={TABLE_ROW_HEIGHT}\n onOpen={(_id, row) => router.push(templateHref(row))}\n />\n )}\n {truncated ? (\n <Alert severity=\"info\">\n {`This site holds more than ${TEMPLATE_CEILING} screens, and the ` +\n 'templates listed are drawn from the first of them. Any beyond ' +\n 'that are not in this list.'}\n </Alert>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nEmailScreensCard.displayName = 'EmailScreensCard'\n\nexport default EmailScreensCard\n"],"names":["buildRoute","pluginDocsHelp","Route","mdiDeleteOutline","mdiContentCopy","mdiEyeOutline","mdiPencilOutline","AppLink","CardDisplay","MdiIcon","useConfirmationContext","ListRowActions","ListTable","listActionsColumn","TABLE_ROW_HEIGHT","useSnackbar","ceilingedWindow","collectionCeiling","DUPLICATE_MENU_LABEL","useConsoleHostRoute","useDuplicateResource","useFirestore","useFirestoreCollection","useHostResourceApi","useHostVersionApi","Alert","Button","Chip","Stack","Typography","collection","doc","Timestamp","updateDoc","useRouter","useMemo","templateProvenance","createEmailScreen","TEMPLATE_CEILING","besignerHref","orgSlug","host","screenId","versionId","SCREEN_BESIGNER","EmailScreensCard","props","hostId","basePath","subdomain","firestore","createHostResource","createHostVersion","router","enqueueSnackbar","duplicate","onDuplicated","_kind","copy","name","variant","confirm","data","screenDocs","idField","rows","readScreens","truncated","emailScreens","filter","screen","deletedAt","kind","sort","a","b","String","displayName","localeCompare","handleCreate","push","error","console","message","handleDelete","confirmed","title","description","confirmationText","confirmationButtonProps","color","then","catch","$id","now","templateHref","templateName","rowActions","key","label","icon","path","size","href","undefined","disabled","disabledReason","onClick","request","id","destructive","columns","field","headerName","flex","minWidth","valueGetter","_value","row","renderCell","value","event","stopPropagation","width","origin","items","header","help","anchor","contentGutterX","contentGutterY","HeaderProps","action","dialog","spacing","length","aria-label","rowHeight","onOpen","_id","severity"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/email-screens-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 { buildRoute, pluginDocsHelp, Route } from '@aglyn/aglyn'\nimport {\n mdiDeleteOutline,\n mdiContentCopy,\n mdiEyeOutline,\n mdiPencilOutline,\n} from '@aglyn/shared-data-mdi'\nimport {\n AppLink,\n CardDisplay,\n MdiIcon,\n useConfirmationContext,\n} from '@aglyn/shared-ui-jsx'\nimport ListFilterChips from '@aglyn/shared-ui-jsx/components/list-filter-chips.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { inMemoryListField } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useListRowsFilter } from '@aglyn/shared-ui-jsx/hooks/use-list-rows-filter'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n ceilingedWindow,\n collectionCeiling,\n} from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport {\n DUPLICATE_MENU_LABEL,\n useConsoleHostRoute,\n useDuplicateResource,\n useFirestore,\n useFirestoreCollection,\n useHostResourceApi,\n useHostVersionApi,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Button,\n Chip,\n Stack,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport { collection, doc, Timestamp, updateDoc } from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useMemo } from 'react'\nimport { templateProvenance } from '../model/template-provenance'\nimport { createEmailScreen } from '../utils/create-email-screen'\n\n/** How many of the site's screens one read of this list covers. */\nconst TEMPLATE_CEILING = 200\n\n/*\n * What the templates grid's Filters panel offers (AGL-3317). The card holds\n * the templates its capped window read, so it answers the panel over all of\n * them; the notice under the table says when the cap truncated the window.\n * Origin reads `originKey`, the provenance the column draws.\n */\nconst TEMPLATE_FILTER_FIELDS = [\n inMemoryListField('displayName', 'text', 'templateName'),\n inMemoryListField('origin', 'select', 'originKey'),\n]\nconst TEMPLATE_FILTER_HEADERS: Readonly<Record<string, string>> = {\n displayName: 'Template',\n origin: 'Origin',\n}\nconst TEMPLATE_FILTER_OPTIONS = {\n origin: [\n { value: 'local', label: 'Yours' },\n { value: 'installed', label: 'Installed' },\n ],\n}\nconst TEMPLATE_SEARCH_FIELDS = ['templateName'] as const\n\n// The besigner route is `/[orgSlug]/hosts/[host]/screens/[screenId]/\n// versions/[versionId]/besigner`. This built `/{hostDocId}/screens/…`, the\n// pre-AGL-621/622 shape — so every \"Edit\"/\"Design\" jump out of the Emails\n// page landed on a 404, including the one right after creating a new email\n// (AGL-685). Takes the resolved org slug + subdomain, not a host doc id.\nconst besignerHref = (\n orgSlug: string,\n host: string,\n screenId: string,\n versionId: string,\n) =>\n buildRoute(Route.SCREEN_BESIGNER, { orgSlug, host, screenId, versionId })\n\n/**\n * THE TEMPLATES: reusable besigner documents an email is built from.\n *\n * A template is a screen document with `kind: 'email'`, kept out of the main\n * Screens list and opened in the besigner with only email-safe components on\n * offer. It is not itself a message — a message is what a campaign sends, and\n * one template can be behind many of them, which is why the row leads to the\n * template's own page rather than straight into the editor.\n *\n * A template is not necessarily this org's. One installed from a marketplace\n * listing appears here beside the locally authored ones, carries its\n * publisher's provenance on the same document, and opens the same detail\n * page; the chip beside its name is what distinguishes them.\n *\n * ## A table, on the surface's own row grammar\n *\n * The row opens the template, its name is ALSO a real link so it can be\n * middle-clicked and copied, and editing and deleting live behind the shared\n * overflow menu rather than as text buttons in the row — the same grammar the\n * audiences table reads by. Delete in particular: it sat inline, one mis-click\n * from the name beside it.\n *\n * ## Why the read is a CEILING with a probe\n *\n * `screens` holds every kind of screen the site has and the email ones are\n * picked out here, so the cap cannot be a page: a page of the collection is\n * not a page of this list. `collectionCeiling` orders on the document name,\n * which is the one ordering that cannot drop a screen — `orderBy('displayName')`\n * matches only documents that HAVE the field, so a screen created without a\n * name would vanish rather than sort oddly. It reads one past the ceiling so\n * the card can SAY when the site holds more than it drew, and the footer under\n * the table pages the window the card already has.\n */\nexport function EmailScreensCard(props: {\n hostId: string\n /** The emails hub URL, which every template route hangs beneath. */\n basePath: string\n}) {\n const { hostId, basePath } = props\n const { orgSlug, subdomain } = useConsoleHostRoute(hostId)\n const firestore = useFirestore()\n const createHostResource = useHostResourceApi()\n const createHostVersion = useHostVersionApi()\n const router = useRouter()\n const { enqueueSnackbar } = useSnackbar()\n // Duplicate (AGL-2936): the copy is a design of its own, sent by nothing\n // until a campaign picks it.\n const duplicate = useDuplicateResource({\n hostId,\n onDuplicated: (_kind, copy) =>\n enqueueSnackbar(`Duplicated as “${copy.name}”`, { variant: 'success' }),\n })\n const { confirm } = useConfirmationContext()\n\n const { data: screenDocs } = useFirestoreCollection<any>(\n () =>\n collectionCeiling(\n collection(firestore, 'hosts', hostId, 'screens'),\n TEMPLATE_CEILING,\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n const { rows: readScreens, truncated } = ceilingedWindow<any>(\n screenDocs,\n TEMPLATE_CEILING,\n )\n const emailScreens = useMemo(\n () =>\n [...readScreens]\n .filter((screen: any) => !screen.deletedAt && screen.kind === 'email')\n .sort((a: any, b: any) =>\n String(a.displayName ?? '').localeCompare(\n String(b.displayName ?? ''),\n ),\n )\n .map((screen: any) => ({\n ...screen,\n templateName: String(screen.displayName ?? 'Untitled template'),\n originKey:\n templateProvenance(screen).origin === 'installed' ? 'installed' : 'local',\n })),\n [readScreens],\n )\n const templateFilter = useListRowsFilter<any>({\n rows: emailScreens,\n fields: TEMPLATE_FILTER_FIELDS,\n options: TEMPLATE_FILTER_OPTIONS,\n headers: TEMPLATE_FILTER_HEADERS,\n search: TEMPLATE_SEARCH_FIELDS,\n })\n\n const handleCreate = async () => {\n try {\n const { screenId, versionId } = await createEmailScreen(\n hostId,\n createHostResource,\n createHostVersion,\n )\n if (orgSlug && subdomain) {\n void router.push(besignerHref(orgSlug, subdomain, screenId, versionId))\n }\n } catch (error: any) {\n console.error(error)\n enqueueSnackbar(error?.message ?? 'Creating the template failed', {\n variant: 'error',\n })\n }\n }\n\n const handleDelete = async (screen: any) => {\n const confirmed = await confirm({\n title: 'Delete this template?',\n description:\n `\"${screen.displayName ?? 'Untitled template'}\" will be removed. ` +\n 'Emails already sent from it keep their reports.',\n confirmationText: 'Delete',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n await updateDoc(doc(firestore, 'hosts', hostId, 'screens', screen.$id), {\n deletedAt: Timestamp.now(),\n })\n }\n\n const templateHref = (screen: any) => `${basePath}/templates/${screen.$id}`\n\n const templateName = (screen: any) =>\n String(screen.displayName ?? 'Untitled template')\n\n const rowActions = (screen: any): RowActionsMenuItem[] => [\n {\n key: 'details',\n label: 'Open details',\n icon: <MdiIcon path={mdiEyeOutline.path} size={0.8} />,\n href: templateHref(screen),\n },\n {\n key: 'besigner',\n label: 'Edit in besigner',\n icon: <MdiIcon path={mdiPencilOutline.path} size={0.8} />,\n /*\n The editor's URL needs the resolved org slug and subdomain. Until the\n host route settles both are empty and a link built from them lands on\n `/null/hosts/null`, so the item is shown DISABLED with the reason\n rather than pointing at a 404.\n */\n href:\n orgSlug && subdomain\n ? besignerHref(orgSlug, subdomain, screen.$id, screen.versionId)\n : undefined,\n disabled: !orgSlug || !subdomain,\n disabledReason: 'This site’s console URL has not resolved yet',\n },\n {\n key: 'duplicate',\n label: DUPLICATE_MENU_LABEL,\n icon: <MdiIcon path={mdiContentCopy.path} size={0.8} />,\n onClick: () =>\n duplicate.request('emailDesign', {\n id: screen.$id,\n name: templateName(screen),\n }),\n },\n {\n key: 'delete',\n label: 'Delete',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleDelete(screen),\n },\n ]\n\n /*\n * The templates are a window this card already holds, so the grid pages\n * it. The name is a link AND the row opens the template.\n */\n const columns: GridColDef[] = [\n {\n field: 'displayName',\n headerName: 'Template',\n flex: 1,\n minWidth: 220,\n valueGetter: (_value, row) => templateName(row),\n renderCell: ({ row, value }) => (\n <AppLink\n href={templateHref(row)}\n // The row's own handler would fire too and push the same route\n // twice — one history entry per back press.\n onClick={(event: { stopPropagation: () => void }) =>\n event.stopPropagation()\n }\n >\n {value}\n </AppLink>\n ),\n },\n {\n /*\n WHOSE template this is, where the reader is choosing between them. An\n installed one is versioned by its publisher and can be withdrawn, which\n is not a property a name can carry.\n */\n field: 'origin',\n headerName: 'Origin',\n width: 140,\n valueGetter: (_value, row) => row.originKey,\n renderCell: ({ value }) =>\n value === 'installed' ? (\n <Chip size=\"small\" label=\"Installed\" />\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Yours'}\n </Typography>\n ),\n },\n listActionsColumn(\n (row) => (\n <ListRowActions label={templateName(row)} items={rowActions(row)} />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <CardDisplay\n header={'Templates'}\n help={pluginDocsHelp('designedEmails', { anchor: '#create-a-template' })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Button\n size=\"small\"\n variant=\"contained\"\n onClick={() => void handleCreate()}\n >\n {'New template'}\n </Button>\n ),\n }}\n >\n {duplicate.dialog}\n <Stack spacing={1.5}>\n {emailScreens.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Design a reusable email here, then send it from a campaign. A ' +\n 'new template opens in the besigner with email-safe components ' +\n 'only.'}\n </Typography>\n ) : (\n <>\n <ListFilterChips {...templateFilter.chipsProps} />\n <ListTable\n aria-label=\"Email templates\"\n rows={templateFilter.rows}\n columns={templateFilter.filterColumns(columns)}\n rowHeight={TABLE_ROW_HEIGHT}\n onOpen={(_id, row) => router.push(templateHref(row))}\n // The panel and the search are the grid's; the card answers\n // them over every template its window read.\n {...templateFilter.gridProps}\n noRowsLabel=\"No templates match these filters\"\n />\n </>\n )}\n {truncated ? (\n <Alert severity=\"info\">\n {`This site holds more than ${TEMPLATE_CEILING} screens, and the ` +\n 'templates listed are drawn from the first of them. Any beyond ' +\n 'that are not in this list.'}\n </Alert>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nEmailScreensCard.displayName = 'EmailScreensCard'\n\nexport default EmailScreensCard\n"],"names":["buildRoute","pluginDocsHelp","Route","mdiDeleteOutline","mdiContentCopy","mdiEyeOutline","mdiPencilOutline","AppLink","CardDisplay","MdiIcon","useConfirmationContext","ListFilterChips","ListRowActions","ListTable","listActionsColumn","inMemoryListField","TABLE_ROW_HEIGHT","useListRowsFilter","useSnackbar","ceilingedWindow","collectionCeiling","DUPLICATE_MENU_LABEL","useConsoleHostRoute","useDuplicateResource","useFirestore","useFirestoreCollection","useHostResourceApi","useHostVersionApi","Alert","Button","Chip","Stack","Typography","collection","doc","Timestamp","updateDoc","useRouter","useMemo","templateProvenance","createEmailScreen","TEMPLATE_CEILING","TEMPLATE_FILTER_FIELDS","TEMPLATE_FILTER_HEADERS","displayName","origin","TEMPLATE_FILTER_OPTIONS","value","label","TEMPLATE_SEARCH_FIELDS","besignerHref","orgSlug","host","screenId","versionId","SCREEN_BESIGNER","EmailScreensCard","props","hostId","basePath","subdomain","firestore","createHostResource","createHostVersion","router","enqueueSnackbar","duplicate","onDuplicated","_kind","copy","name","variant","confirm","data","screenDocs","idField","rows","readScreens","truncated","emailScreens","filter","screen","deletedAt","kind","sort","a","b","String","localeCompare","map","templateName","originKey","templateFilter","fields","options","headers","search","handleCreate","push","error","console","message","handleDelete","confirmed","title","description","confirmationText","confirmationButtonProps","color","then","catch","$id","now","templateHref","rowActions","key","icon","path","size","href","undefined","disabled","disabledReason","onClick","request","id","destructive","columns","field","headerName","flex","minWidth","valueGetter","_value","row","renderCell","event","stopPropagation","width","items","header","help","anchor","contentGutterX","contentGutterY","HeaderProps","action","dialog","spacing","length","chipsProps","aria-label","filterColumns","rowHeight","onOpen","_id","gridProps","noRowsLabel","severity"],"mappings":"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"}
|
|
@@ -61,52 +61,24 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
61
61
|
*/ import { readMarketingBasis } from "@aglyn/aglyn";
|
|
62
62
|
import { mdiAccountRemoveOutline } from "@aglyn/shared-data-mdi";
|
|
63
63
|
import { MdiIcon, useConfirmationContext } from "@aglyn/shared-ui-jsx";
|
|
64
|
+
import ListFilterChips from "@aglyn/shared-ui-jsx/components/list-filter-chips.component";
|
|
64
65
|
import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
|
|
65
66
|
import { ListRowActions, ListTable, listActionsColumn } from "@aglyn/shared-ui-jsx/components/list-table.component";
|
|
67
|
+
import { inMemoryListField } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
|
|
66
68
|
import { TABLE_ROW_HEIGHT } from "@aglyn/shared-ui-jsx/const/table-pagination";
|
|
69
|
+
import { usePagedRowsFilter } from "@aglyn/shared-ui-jsx/hooks/use-paged-rows-filter";
|
|
67
70
|
import { useSnackbar } from "@aglyn/shared-ui-snackstack";
|
|
68
71
|
import { Alert, Box, Button, Checkbox, Chip, FormControlLabel, Stack, TextField, Typography } from "@mui/material";
|
|
69
72
|
import { collection, deleteDoc, doc, documentId, limit, orderBy, query } from "firebase/firestore";
|
|
70
|
-
import { useCallback, useEffect, useState } from "react";
|
|
73
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
71
74
|
import { useFirestore, usePagedCollection, useUser } from "@aglyn/tenant-feature-instance";
|
|
72
75
|
import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
|
|
73
|
-
|
|
74
|
-
* How the basis reads on screen, for the group this console is viewed as.
|
|
75
|
-
*
|
|
76
|
-
* ## Read through `readMarketingBasis`, never off the row
|
|
77
|
-
*
|
|
78
|
-
* This picked `marketingConsent`, `marketingConsentBasis` and
|
|
79
|
-
* `marketingConsentAtMs` off the top of the membership document, which is
|
|
80
|
-
* where `enrollListMember` used to put them. They now live in an entry per
|
|
81
|
-
* site under `marketingConsentByHost`, because a basis runs to the brand it
|
|
82
|
-
* was given to rather than to the account that holds the row. Nothing here
|
|
83
|
-
* moved with them, so every top-level read answered `undefined` and every
|
|
84
|
-
* member of every list — including people carrying a perfectly good attested
|
|
85
|
-
* basis — was reported as having no basis on record, on the one screen an
|
|
86
|
-
* operator consults before mailing them. `performCampaignSend` reads the same
|
|
87
|
-
* documents through the function below and mailed them, correctly, at the same
|
|
88
|
-
* time.
|
|
89
|
-
*
|
|
90
|
-
* The fix is not a second field name. It is that the send path's reader is the
|
|
91
|
-
* only reader: a surface that parses the stored shape itself agrees with the
|
|
92
|
-
* sender only until the shape moves, and it moves without anybody thinking to
|
|
93
|
-
* look here.
|
|
94
|
-
*
|
|
95
|
-
* ## Five states, because the model carries five
|
|
96
|
-
*
|
|
97
|
-
* An attestation and an opt-in are not the same fact — one is a person's own
|
|
98
|
-
* decision, the other a claim an account made on their behalf — and a table
|
|
99
|
-
* rendering both as a tick is the conflation `list-members.ts` stores the
|
|
100
|
-
* basis to prevent. The three that are not a grant are not one state either.
|
|
101
|
-
* Absence is not refusal, and neither of them is "opted in, to somebody else
|
|
102
|
-
* in this account": that third one is a person the sender deliberately leaves
|
|
103
|
-
* alone, and an operator who cannot see why reads it as the product having
|
|
104
|
-
* lost their subscriber.
|
|
105
|
-
*/ function consentLabel(member, group) {
|
|
76
|
+
function consentLabel(member, group) {
|
|
106
77
|
const record = readMarketingBasis(member, group);
|
|
107
78
|
const when = record.basisAtMs ? ` · ${new Date(record.basisAtMs).toLocaleDateString()}` : '';
|
|
108
79
|
if (record.basis === 'declined') {
|
|
109
80
|
return {
|
|
81
|
+
key: 'opted-out',
|
|
110
82
|
label: `Opted out${when}`,
|
|
111
83
|
color: 'error'
|
|
112
84
|
};
|
|
@@ -118,9 +90,11 @@ import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
|
|
|
118
90
|
* `marketingConsentBasis`, and the provenance object a backfill writes —
|
|
119
91
|
* and the reader has already resolved both into this one answer.
|
|
120
92
|
*/ return record.assertedBy === 'operator' ? {
|
|
93
|
+
key: 'attested',
|
|
121
94
|
label: `Attested by your team${when}`,
|
|
122
95
|
color: 'warning'
|
|
123
96
|
} : {
|
|
97
|
+
key: 'opted-in',
|
|
124
98
|
label: `Opted in${when}`,
|
|
125
99
|
color: 'default'
|
|
126
100
|
};
|
|
@@ -132,21 +106,72 @@ import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
|
|
|
132
106
|
* of another brand in this account and this brand may not borrow it.
|
|
133
107
|
*/ if (record.otherGrant === 'other-host') {
|
|
134
108
|
return {
|
|
109
|
+
key: 'other-site',
|
|
135
110
|
label: 'Opted in to another site',
|
|
136
111
|
color: 'warning'
|
|
137
112
|
};
|
|
138
113
|
}
|
|
139
114
|
if (record.otherGrant === 'unscoped') {
|
|
140
115
|
return {
|
|
116
|
+
key: 'no-site',
|
|
141
117
|
label: 'Opted in — no site recorded',
|
|
142
118
|
color: 'warning'
|
|
143
119
|
};
|
|
144
120
|
}
|
|
145
121
|
return {
|
|
122
|
+
key: 'none',
|
|
146
123
|
label: 'No basis on record',
|
|
147
124
|
color: 'default'
|
|
148
125
|
};
|
|
149
126
|
}
|
|
127
|
+
/*
|
|
128
|
+
* What the membership grid's Filters panel offers (AGL-3317). The table is a
|
|
129
|
+
* paged listener, so a filter matches over what its window has read, which
|
|
130
|
+
* the first filter widens (`usePagedRowsFilter`). `How` and `Consent` read
|
|
131
|
+
* the keys `withFilterKeys` derives, the same answers the columns draw.
|
|
132
|
+
*/ const MEMBER_FILTER_FIELDS = [
|
|
133
|
+
inMemoryListField('email', 'text'),
|
|
134
|
+
inMemoryListField('name', 'text'),
|
|
135
|
+
inMemoryListField('via', 'select', 'viaKey'),
|
|
136
|
+
inMemoryListField('consent', 'select', 'consentKey')
|
|
137
|
+
];
|
|
138
|
+
const MEMBER_FILTER_HEADERS = {
|
|
139
|
+
email: 'Address',
|
|
140
|
+
name: 'Name',
|
|
141
|
+
via: 'How',
|
|
142
|
+
consent: 'Consent'
|
|
143
|
+
};
|
|
144
|
+
const VIA_LABELS = {
|
|
145
|
+
rule: 'Rule',
|
|
146
|
+
manual: 'Added'
|
|
147
|
+
};
|
|
148
|
+
const CONSENT_LABELS = {
|
|
149
|
+
'opted-in': 'Opted in',
|
|
150
|
+
attested: 'Attested by your team',
|
|
151
|
+
'opted-out': 'Opted out',
|
|
152
|
+
'other-site': 'Opted in to another site',
|
|
153
|
+
'no-site': 'Opted in — no site recorded',
|
|
154
|
+
none: 'No basis on record'
|
|
155
|
+
};
|
|
156
|
+
const MEMBER_FILTER_OPTIONS = {
|
|
157
|
+
via: Object.entries(VIA_LABELS).map(([value, label])=>({
|
|
158
|
+
value,
|
|
159
|
+
label
|
|
160
|
+
})),
|
|
161
|
+
consent: Object.entries(CONSENT_LABELS).map(([value, label])=>({
|
|
162
|
+
value,
|
|
163
|
+
label
|
|
164
|
+
}))
|
|
165
|
+
};
|
|
166
|
+
const MEMBER_SEARCH_FIELDS = [
|
|
167
|
+
'email',
|
|
168
|
+
'name',
|
|
169
|
+
'source'
|
|
170
|
+
];
|
|
171
|
+
const withFilterKeys = (member, group)=>_extends({}, member, {
|
|
172
|
+
viaKey: member.via === 'rule' ? 'rule' : 'manual',
|
|
173
|
+
consentKey: consentLabel(member, group).key
|
|
174
|
+
});
|
|
150
175
|
/** Free text — a paste, a typed address, commas or newlines — to addresses. */ export function splitAddresses(value) {
|
|
151
176
|
return value.split(/[\s,;]+/).map((entry)=>entry.trim())// A pasted mail-client line is `Name <a@b.co>`; keep the address part.
|
|
152
177
|
.map((entry)=>entry.replace(/^.*<|>.*$/g, '')).filter(Boolean);
|
|
@@ -168,7 +193,7 @@ export function ListMembersPanel(props) {
|
|
|
168
193
|
* oldest members from their own list, silently, which is the failure that
|
|
169
194
|
* turned an audience into a random sample (AGL-2501). The id is the one key
|
|
170
195
|
* every row has.
|
|
171
|
-
*/ const { rows:
|
|
196
|
+
*/ const { data: memberData, rows: memberRows, hasMore, page, setPage, pageSize, setPageSize } = usePagedCollection((pageLimit)=>query(collection(firestore, scope[0], scope[1], 'lists', listId, 'members'), orderBy(documentId()), limit(pageLimit)), [
|
|
172
197
|
firestore,
|
|
173
198
|
scope[0],
|
|
174
199
|
scope[1],
|
|
@@ -176,6 +201,30 @@ export function ListMembersPanel(props) {
|
|
|
176
201
|
], {
|
|
177
202
|
idField: '$id'
|
|
178
203
|
});
|
|
204
|
+
const memberWindow = useMemo(()=>memberData == null ? void 0 : memberData.map((member)=>withFilterKeys(member, consentGroup)), [
|
|
205
|
+
memberData,
|
|
206
|
+
consentGroup
|
|
207
|
+
]);
|
|
208
|
+
const memberPage = useMemo(()=>memberRows.map((member)=>withFilterKeys(member, consentGroup)), [
|
|
209
|
+
memberRows,
|
|
210
|
+
consentGroup
|
|
211
|
+
]);
|
|
212
|
+
const memberFilter = usePagedRowsFilter({
|
|
213
|
+
data: memberWindow,
|
|
214
|
+
rows: memberPage,
|
|
215
|
+
hasMore,
|
|
216
|
+
page,
|
|
217
|
+
setPage,
|
|
218
|
+
pageSize,
|
|
219
|
+
setPageSize
|
|
220
|
+
}, {
|
|
221
|
+
fields: MEMBER_FILTER_FIELDS,
|
|
222
|
+
options: MEMBER_FILTER_OPTIONS,
|
|
223
|
+
headers: MEMBER_FILTER_HEADERS,
|
|
224
|
+
search: MEMBER_SEARCH_FIELDS
|
|
225
|
+
});
|
|
226
|
+
// The rows on screen: the page, or the page of the matches.
|
|
227
|
+
const members = memberFilter.rows;
|
|
179
228
|
const [addInput, setAddInput] = useState('');
|
|
180
229
|
const [addName, setAddName] = useState('');
|
|
181
230
|
const [preview, setPreview] = useState(null);
|
|
@@ -434,13 +483,13 @@ export function ListMembersPanel(props) {
|
|
|
434
483
|
field: 'via',
|
|
435
484
|
headerName: 'How',
|
|
436
485
|
width: 200,
|
|
437
|
-
valueGetter: (_value, row)=>row.
|
|
486
|
+
valueGetter: (_value, row)=>row.viaKey,
|
|
438
487
|
/*
|
|
439
488
|
`via` says whether a rule put them here or a person did, which is what
|
|
440
489
|
decides whether they LEAVE on their own: the materializer reconciles its
|
|
441
490
|
own rows away and never touches a manual one. `source` is the finer
|
|
442
491
|
provenance underneath it.
|
|
443
|
-
*/ renderCell: ({ row
|
|
492
|
+
*/ renderCell: ({ row })=>/*#__PURE__*/ _jsxs(Stack, {
|
|
444
493
|
direction: "row",
|
|
445
494
|
spacing: 1,
|
|
446
495
|
sx: {
|
|
@@ -451,7 +500,7 @@ export function ListMembersPanel(props) {
|
|
|
451
500
|
/*#__PURE__*/ _jsx(Chip, {
|
|
452
501
|
size: "small",
|
|
453
502
|
variant: "outlined",
|
|
454
|
-
label:
|
|
503
|
+
label: VIA_LABELS[row.viaKey]
|
|
455
504
|
}),
|
|
456
505
|
row.source ? /*#__PURE__*/ _jsx(Typography, {
|
|
457
506
|
variant: "caption",
|
|
@@ -466,7 +515,7 @@ export function ListMembersPanel(props) {
|
|
|
466
515
|
field: 'consent',
|
|
467
516
|
headerName: 'Consent',
|
|
468
517
|
width: 190,
|
|
469
|
-
valueGetter: (_value, row)=>
|
|
518
|
+
valueGetter: (_value, row)=>row.consentKey,
|
|
470
519
|
renderCell: ({ row })=>{
|
|
471
520
|
const consent = consentLabel(row, consentGroup);
|
|
472
521
|
return /*#__PURE__*/ _jsx(Chip, {
|
|
@@ -643,28 +692,29 @@ export function ListMembersPanel(props) {
|
|
|
643
692
|
}, result.input);
|
|
644
693
|
})
|
|
645
694
|
}) : null,
|
|
646
|
-
|
|
695
|
+
memberRows.length === 0 && !hasMore && !memberFilter.filtering ? /*#__PURE__*/ _jsx(Typography, {
|
|
647
696
|
variant: "body2",
|
|
648
697
|
color: "text.secondary",
|
|
649
698
|
children: 'Nobody is on this list yet.'
|
|
650
699
|
}) : /*#__PURE__*/ _jsxs(_Fragment, {
|
|
651
700
|
children: [
|
|
652
|
-
/*#__PURE__*/ _jsx(
|
|
701
|
+
/*#__PURE__*/ _jsx(ListFilterChips, _extends({}, memberFilter.chipsProps)),
|
|
702
|
+
memberFilter.filtering && hasMore ? /*#__PURE__*/ _jsx(Typography, {
|
|
703
|
+
variant: "caption",
|
|
704
|
+
color: "text.secondary",
|
|
705
|
+
children: `Filtering the ${memberFilter.read} members read so far — the next page reads more.`
|
|
706
|
+
}) : null,
|
|
707
|
+
/*#__PURE__*/ _jsx(ListTable, _extends({
|
|
653
708
|
"aria-label": `Members of ${listName}`,
|
|
654
709
|
rows: members,
|
|
655
|
-
columns: columns,
|
|
710
|
+
columns: memberFilter.filterColumns(columns),
|
|
656
711
|
rowHeight: TABLE_ROW_HEIGHT,
|
|
657
712
|
// Paged by the footer below, so the grid must not also slice.
|
|
658
713
|
hideFooter: true
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
rowCount: members.length,
|
|
664
|
-
hasMore: hasMore,
|
|
665
|
-
onPageChange: setPage,
|
|
666
|
-
onPageSizeChange: setPageSize
|
|
667
|
-
})
|
|
714
|
+
}, memberFilter.gridProps, {
|
|
715
|
+
noRowsLabel: "No members match these filters"
|
|
716
|
+
})),
|
|
717
|
+
/*#__PURE__*/ _jsx(ListPagination, _extends({}, memberFilter.pagination))
|
|
668
718
|
]
|
|
669
719
|
}),
|
|
670
720
|
/*#__PURE__*/ _jsx(Typography, {
|