@aglyn/plugins-email 1.0.0-beta.231 → 1.0.0-beta.233
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 +16 -16
- package/src/lib/components/consent-group-change-progress.js +4 -0
- package/src/lib/components/consent-group-change-progress.js.map +1 -1
- package/src/lib/components/consent-group-site-picker.js +1 -0
- package/src/lib/components/consent-group-site-picker.js.map +1 -1
- package/src/lib/components/email-screens-card.js +33 -6
- package/src/lib/components/email-screens-card.js.map +1 -1
- package/src/lib/components/email-template-detail.js +3 -1
- package/src/lib/components/email-template-detail.js.map +1 -1
- package/src/lib/components/email-topics-card.js +1 -0
- package/src/lib/components/email-topics-card.js.map +1 -1
- package/src/lib/components/list-detail-card.js +4 -0
- package/src/lib/components/list-detail-card.js.map +1 -1
- package/src/lib/components/list-members-panel.js +38 -7
- package/src/lib/components/list-members-panel.js.map +1 -1
- package/src/lib/components/lists-card.js +35 -7
- package/src/lib/components/lists-card.js.map +1 -1
- package/src/lib/components/org-email-templates-card.js +34 -7
- package/src/lib/components/org-email-templates-card.js.map +1 -1
- package/src/lib/components/org-sending-domain-detail.js +1 -0
- package/src/lib/components/org-sending-domain-detail.js.map +1 -1
- package/src/lib/components/sending-domain-detail.js +1 -0
- package/src/lib/components/sending-domain-detail.js.map +1 -1
- package/src/lib/components/suppressions-card.js +25 -6
- package/src/lib/components/suppressions-card.js.map +1 -1
- package/src/lib/constants/list-queries.js +73 -9
- package/src/lib/constants/list-queries.js.map +1 -1
- package/src/lib/model/email-design-document.d.ts +1 -1
- package/src/lib/model/email-design-document.js +1 -1
- package/src/lib/model/email-design-document.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/email-topics-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 {\n createResourceUid,\n isSubscriptionTopicId,\n pluginDocsHelp,\n DECLARED_SUBSCRIPTION_TOPICS,\n} from '@aglyn/aglyn'\nimport { EMAIL_TOPICS_COLLECTION } from '../model/email-topic-catalog'\nimport {\n mdiArchiveArrowUpOutline,\n mdiArchiveOutline,\n mdiPencilOutline,\n} from '@aglyn/shared-data-mdi'\nimport {\n AppLink,\n CardDisplay,\n MdiIcon,\n useConfirmationContext,\n} from '@aglyn/shared-ui-jsx'\nimport RowActionsMenu, {\n type RowActionsMenuItem,\n} from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { CreateArtifactDrawer } from '@aglyn/shared-ui-jsx-forms'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Button,\n Chip,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport { doc, setDoc } from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useState } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useEmailOrgMount } from './email-org-mount'\nimport { useOrgEmailTopics, writeEmailTopic } from './use-org-email-topics'\n\nexport interface EmailTopicsCardProps {\n /** The site, or `null` on the organization's Emails page. */\n hostId: string | null\n /** The emails hub URL, which every topic route hangs beneath. */\n basePath: string\n}\n\n/**\n * Email topics: the streams a recipient can leave one at a time.\n *\n * ## What this closes\n *\n * `docs/specs/email-competitive-gaps.md` §1f is a table of what six\n * competitors have, and the preference-center row was the one where every one\n * of them had something and we had nothing: unsubscribe was all-or-nothing per\n * site. This card is the merchant's half of the fix — the recipient's half is\n * the hosted preference page the campaign footer now links to.\n *\n * ## Why the four built-ins are not documents\n *\n * Every org that exists today has an empty `emailTopics` collection, and a\n * seeding migration would leave any org created while it ran with none. So\n * `DECLARED_SUBSCRIPTION_TOPICS` is the FLOOR of the catalog rather than its initial\n * contents: the four are present for every org with no write anywhere, and\n * saving one on its detail page writes a document at the same id that renames\n * or retires it. A built-in with no stored document is not a missing row, it\n * is a row nobody has needed to change.\n *\n * ## Create is a drawer; edit is the topic's own page\n *\n * The console's standing shape for a list surface: creating opens the shared\n * `CreateArtifactDrawer`, the same one Screens, Components, Layouts and\n * Templates use, and changing an existing record happens on that record's\n * page rather than in a form stacked above the table. So this card lists and\n * routes, and `email-topic-detail.tsx` edits.\n *\n * ## The row grammar the rest of the surface uses\n *\n * The same one the audiences table sets: the row opens the topic, the topic's\n * name is ALSO a real link so it can be middle-clicked and copied, and the\n * secondary actions sit behind the shared `RowActionsMenu` rather than as text\n * buttons in the row. Retire is the reason the menu has more than one entry —\n * it is the only thing that can be done to a topic without opening it, and it\n * is destructive enough that it should not sit one mis-click from the row's\n * own open handler.\n */\nexport function EmailTopicsCard(props: EmailTopicsCardProps) {\n const { hostId, basePath } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n // The catalog is the org's: read through the site under one, and straight\n // from the organization on its own page.\n const orgMount = useEmailOrgMount()\n const { topics, scope } = useOrgEmailTopics(hostId, {\n orgId: orgMount?.orgId,\n })\n\n const [drawerOpen, setDrawerOpen] = useState(false)\n const [error, setError] = useState<unknown>(null)\n const [busyTopicId, setBusyTopicId] = useState('')\n\n /** The topic's own page, which is also its edit page. */\n const topicHref = useCallback(\n (topicId: string) => `${basePath}/topics/${topicId}`,\n [basePath],\n )\n\n const create = useCallback(\n async (values: Record<string, any>) => {\n if (!scope) return\n const name = String(values?.displayName ?? '').trim()\n if (!name) return\n // `createResourceUid` is nanoid's `A-Za-z0-9_-`, so the id it mints\n // always satisfies `isSubscriptionTopicId`. Checked anyway, because this id is\n // signed into every unsubscribe link the topic ever mints and the send\n // path refuses one that is not usable — a topic nothing can be sent\n // under is a row the merchant would have no way to diagnose.\n const id = createResourceUid()\n if (!isSubscriptionTopicId(id)) {\n return void enqueueSnackbar('Could not create the topic', {\n variant: 'error',\n })\n }\n try {\n await setDoc(\n doc(firestore, scope[0], scope[1], EMAIL_TOPICS_COLLECTION, id),\n {\n name,\n description: String(values?.description ?? '').trim(),\n archived: false,\n },\n )\n setDrawerOpen(false)\n setError(null)\n enqueueSnackbar('Topic added', { variant: 'success', persist: false })\n // Straight to the record, which is where every further change is made.\n router.push(topicHref(id))\n } catch (caught) {\n console.error(caught)\n setError(caught)\n }\n },\n [scope, firestore, enqueueSnackbar, topicHref, router],\n )\n\n /**\n * Retire a topic, or bring one back.\n *\n * Retire and not delete: campaigns already sent under a topic minted\n * unsubscribe links carrying its id, those links are sitting in inboxes, and\n * an id that stops resolving is a preference page that cannot name the\n * message the recipient is holding. The Firestore rules refuse a delete for\n * the same reason.\n *\n * Restoring is not asked about — it puts a choice back rather than taking\n * one away, and it is undone by retiring again.\n */\n const toggleArchived = useCallback(\n async (topic: { id: string; name: string; description: string; archived?: boolean }) => {\n if (!scope || busyTopicId) return\n if (!topic.archived) {\n const accepted = await confirm({\n title: `Retire “${topic.name}”?`,\n description:\n 'It leaves the composer and the preference page, so nothing new ' +\n 'can be sent under it and recipients stop seeing it as a choice. ' +\n 'Campaigns already sent under it keep working — their ' +\n 'unsubscribe links still name this topic. You can bring it back ' +\n 'at any time.',\n confirmationText: 'Retire',\n })\n // `confirm` resolves with NO VALUE and REJECTS on cancel, so gating\n // on the resolved value alone makes this always return (AGL-950).\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n }\n setBusyTopicId(topic.id)\n try {\n await writeEmailTopic(firestore, scope, {\n id: topic.id,\n name: topic.name,\n description: topic.description,\n archived: !topic.archived,\n })\n enqueueSnackbar(topic.archived ? 'Topic restored' : 'Topic retired', {\n variant: 'success',\n persist: false,\n })\n } catch (caught) {\n console.error(caught)\n enqueueSnackbar('An error has occurred', { variant: 'error' })\n } finally {\n setBusyTopicId('')\n }\n },\n [scope, busyTopicId, confirm, firestore, enqueueSnackbar],\n )\n\n const rowActions = (topic: {\n id: string\n name: string\n description: string\n archived?: boolean\n }): RowActionsMenuItem[] => [\n {\n key: 'edit',\n label: 'Edit topic',\n icon: <MdiIcon path={mdiPencilOutline.path} size={0.8} />,\n href: topicHref(topic.id),\n },\n {\n key: 'archive',\n label: topic.archived ? 'Restore' : 'Retire',\n icon: (\n <MdiIcon\n path={\n topic.archived\n ? mdiArchiveArrowUpOutline.path\n : mdiArchiveOutline.path\n }\n size={0.8}\n />\n ),\n destructive: !topic.archived,\n disabled: Boolean(busyTopicId),\n disabledReason: busyTopicId ? 'Another topic is being saved' : undefined,\n onClick: () => void toggleArchived(topic),\n },\n ]\n\n return (\n <CardDisplay\n header=\"Topics\"\n help={pluginDocsHelp('emailCampaigns', { anchor: '#topics' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{\n action: scope ? (\n <Button variant=\"contained\" onClick={() => setDrawerOpen(true)}>\n {'New topic'}\n </Button>\n ) : null,\n }}\n >\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The streams your recipients can leave one at a time. Every ' +\n 'campaign belongs to one, and the preference page linked from ' +\n 'each email lets someone stop that stream without stopping ' +\n 'everything. Topics are shared across every site in this ' +\n 'organization, the same way your lists are.'}\n </Typography>\n {!scope ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'This site has no organization, so it has no topic list.'}\n </Typography>\n ) : (\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Topic'}</TableCell>\n <TableCell>{'What recipients are told they get'}</TableCell>\n <TableCell align=\"right\" />\n </TableRow>\n </TableHead>\n <TableBody>\n {topics.map((topic) => {\n const href = topicHref(topic.id)\n return (\n <TableRow\n key={topic.id}\n hover\n onClick={() => router.push(href)}\n sx={{ cursor: 'pointer' }}\n >\n <TableCell>\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ flexWrap: 'wrap', alignItems: 'center' }}\n >\n {/*\n The row's own handler would fire too and push the\n same route twice — one history entry per back press.\n */}\n <AppLink\n href={href}\n onClick={(event: { stopPropagation: () => void }) =>\n event.stopPropagation()\n }\n >\n {topic.name}\n </AppLink>\n {topic.archived ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n color=\"warning\"\n label=\"Retired\"\n />\n ) : null}\n {DECLARED_SUBSCRIPTION_TOPICS.some(\n (it) => it.id === topic.id,\n ) ? (\n <Typography\n variant=\"overline\"\n color=\"text.secondary\"\n >\n {'Built in'}\n </Typography>\n ) : null}\n </Stack>\n </TableCell>\n <TableCell>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {topic.description || '—'}\n </Typography>\n </TableCell>\n <TableCell\n align=\"right\"\n sx={{ width: 56 }}\n onClick={(event) => event.stopPropagation()}\n >\n <RowActionsMenu\n label={topic.name}\n items={rowActions(topic)}\n />\n </TableCell>\n </TableRow>\n )\n })}\n </TableBody>\n </ScrollTable>\n )}\n </Stack>\n <CreateArtifactDrawer\n open={drawerOpen}\n onClose={() => setDrawerOpen(false)}\n title=\"New email topic\"\n submitLabel=\"Create topic\"\n onSubmit={create}\n errorSlot={\n error ? (\n <Typography variant=\"body2\" color=\"error\" sx={{ mt: 2 }}>\n {'Could not create the topic. Try again.'}\n </Typography>\n ) : null\n }\n />\n </CardDisplay>\n )\n}\n\nexport default EmailTopicsCard\n"],"names":["createResourceUid","isSubscriptionTopicId","pluginDocsHelp","DECLARED_SUBSCRIPTION_TOPICS","EMAIL_TOPICS_COLLECTION","mdiArchiveArrowUpOutline","mdiArchiveOutline","mdiPencilOutline","AppLink","CardDisplay","MdiIcon","useConfirmationContext","RowActionsMenu","CreateArtifactDrawer","ScrollTable","useSnackbar","Button","Chip","Stack","TableBody","TableCell","TableHead","TableRow","Typography","doc","setDoc","useRouter","useCallback","useState","useFirestore","useEmailOrgMount","useOrgEmailTopics","writeEmailTopic","EmailTopicsCard","props","hostId","basePath","firestore","router","enqueueSnackbar","confirm","orgMount","topics","scope","orgId","drawerOpen","setDrawerOpen","error","setError","busyTopicId","setBusyTopicId","topicHref","topicId","create","values","name","String","displayName","trim","id","variant","description","archived","persist","push","caught","console","toggleArchived","topic","accepted","title","confirmationText","then","catch","rowActions","key","label","icon","path","size","href","destructive","disabled","Boolean","disabledReason","undefined","onClick","header","help","anchor","contentGutterX","contentGutterY","contentBordered","HeaderProps","action","spacing","color","align","map","hover","sx","cursor","direction","flexWrap","alignItems","event","stopPropagation","some","it","width","items","open","onClose","submitLabel","onSubmit","errorSlot","mt"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/email-topics-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 {\n createResourceUid,\n isSubscriptionTopicId,\n pluginDocsHelp,\n DECLARED_SUBSCRIPTION_TOPICS,\n} from '@aglyn/aglyn'\nimport { EMAIL_TOPICS_COLLECTION } from '../model/email-topic-catalog'\nimport {\n mdiArchiveArrowUpOutline,\n mdiArchiveOutline,\n mdiPencilOutline,\n} from '@aglyn/shared-data-mdi'\nimport {\n AppLink,\n CardDisplay,\n MdiIcon,\n useConfirmationContext,\n} from '@aglyn/shared-ui-jsx'\nimport RowActionsMenu, {\n type RowActionsMenuItem,\n} from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { CreateArtifactDrawer } from '@aglyn/shared-ui-jsx-forms'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Button,\n Chip,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\nimport { doc, setDoc } from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useState } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useEmailOrgMount } from './email-org-mount'\nimport { useOrgEmailTopics, writeEmailTopic } from './use-org-email-topics'\n\nexport interface EmailTopicsCardProps {\n /** The site, or `null` on the organization's Emails page. */\n hostId: string | null\n /** The emails hub URL, which every topic route hangs beneath. */\n basePath: string\n}\n\n/**\n * Email topics: the streams a recipient can leave one at a time.\n *\n * ## What this closes\n *\n * `docs/specs/email-competitive-gaps.md` §1f is a table of what six\n * competitors have, and the preference-center row was the one where every one\n * of them had something and we had nothing: unsubscribe was all-or-nothing per\n * site. This card is the merchant's half of the fix — the recipient's half is\n * the hosted preference page the campaign footer now links to.\n *\n * ## Why the four built-ins are not documents\n *\n * Every org that exists today has an empty `emailTopics` collection, and a\n * seeding migration would leave any org created while it ran with none. So\n * `DECLARED_SUBSCRIPTION_TOPICS` is the FLOOR of the catalog rather than its initial\n * contents: the four are present for every org with no write anywhere, and\n * saving one on its detail page writes a document at the same id that renames\n * or retires it. A built-in with no stored document is not a missing row, it\n * is a row nobody has needed to change.\n *\n * ## Create is a drawer; edit is the topic's own page\n *\n * The console's standing shape for a list surface: creating opens the shared\n * `CreateArtifactDrawer`, the same one Screens, Components, Layouts and\n * Templates use, and changing an existing record happens on that record's\n * page rather than in a form stacked above the table. So this card lists and\n * routes, and `email-topic-detail.tsx` edits.\n *\n * ## The row grammar the rest of the surface uses\n *\n * The same one the audiences table sets: the row opens the topic, the topic's\n * name is ALSO a real link so it can be middle-clicked and copied, and the\n * secondary actions sit behind the shared `RowActionsMenu` rather than as text\n * buttons in the row. Retire is the reason the menu has more than one entry —\n * it is the only thing that can be done to a topic without opening it, and it\n * is destructive enough that it should not sit one mis-click from the row's\n * own open handler.\n */\nexport function EmailTopicsCard(props: EmailTopicsCardProps) {\n const { hostId, basePath } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n // The catalog is the org's: read through the site under one, and straight\n // from the organization on its own page.\n const orgMount = useEmailOrgMount()\n const { topics, scope } = useOrgEmailTopics(hostId, {\n orgId: orgMount?.orgId,\n })\n\n const [drawerOpen, setDrawerOpen] = useState(false)\n const [error, setError] = useState<unknown>(null)\n const [busyTopicId, setBusyTopicId] = useState('')\n\n /** The topic's own page, which is also its edit page. */\n const topicHref = useCallback(\n (topicId: string) => `${basePath}/topics/${topicId}`,\n [basePath],\n )\n\n const create = useCallback(\n async (values: Record<string, any>) => {\n if (!scope) return\n const name = String(values?.displayName ?? '').trim()\n if (!name) return\n // `createResourceUid` is nanoid's `A-Za-z0-9_-`, so the id it mints\n // always satisfies `isSubscriptionTopicId`. Checked anyway, because this id is\n // signed into every unsubscribe link the topic ever mints and the send\n // path refuses one that is not usable — a topic nothing can be sent\n // under is a row the merchant would have no way to diagnose.\n const id = createResourceUid()\n if (!isSubscriptionTopicId(id)) {\n return void enqueueSnackbar('Could not create the topic', {\n variant: 'error',\n })\n }\n try {\n await setDoc(\n doc(firestore, scope[0], scope[1], EMAIL_TOPICS_COLLECTION, id),\n {\n name,\n description: String(values?.description ?? '').trim(),\n archived: false,\n },\n )\n setDrawerOpen(false)\n setError(null)\n enqueueSnackbar('Topic added', { variant: 'success', persist: false })\n // Straight to the record, which is where every further change is made.\n router.push(topicHref(id))\n } catch (caught) {\n console.error(caught)\n setError(caught)\n }\n },\n [scope, firestore, enqueueSnackbar, topicHref, router],\n )\n\n /**\n * Retire a topic, or bring one back.\n *\n * Retire and not delete: campaigns already sent under a topic minted\n * unsubscribe links carrying its id, those links are sitting in inboxes, and\n * an id that stops resolving is a preference page that cannot name the\n * message the recipient is holding. The Firestore rules refuse a delete for\n * the same reason.\n *\n * Restoring is not asked about — it puts a choice back rather than taking\n * one away, and it is undone by retiring again.\n */\n const toggleArchived = useCallback(\n async (topic: { id: string; name: string; description: string; archived?: boolean }) => {\n if (!scope || busyTopicId) return\n if (!topic.archived) {\n const accepted = await confirm({\n title: `Retire “${topic.name}”?`,\n description:\n 'It leaves the composer and the preference page, so nothing new ' +\n 'can be sent under it and recipients stop seeing it as a choice. ' +\n 'Campaigns already sent under it keep working — their ' +\n 'unsubscribe links still name this topic. You can bring it back ' +\n 'at any time.',\n confirmationText: 'Retire',\n })\n // `confirm` resolves with NO VALUE and REJECTS on cancel, so gating\n // on the resolved value alone makes this always return (AGL-950).\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n }\n setBusyTopicId(topic.id)\n try {\n await writeEmailTopic(firestore, scope, {\n id: topic.id,\n name: topic.name,\n description: topic.description,\n archived: !topic.archived,\n })\n enqueueSnackbar(topic.archived ? 'Topic restored' : 'Topic retired', {\n variant: 'success',\n persist: false,\n })\n } catch (caught) {\n console.error(caught)\n enqueueSnackbar('An error has occurred', { variant: 'error' })\n } finally {\n setBusyTopicId('')\n }\n },\n [scope, busyTopicId, confirm, firestore, enqueueSnackbar],\n )\n\n const rowActions = (topic: {\n id: string\n name: string\n description: string\n archived?: boolean\n }): RowActionsMenuItem[] => [\n {\n key: 'edit',\n label: 'Edit topic',\n icon: <MdiIcon path={mdiPencilOutline.path} size={0.8} />,\n href: topicHref(topic.id),\n },\n {\n key: 'archive',\n label: topic.archived ? 'Restore' : 'Retire',\n icon: (\n <MdiIcon\n path={\n topic.archived\n ? mdiArchiveArrowUpOutline.path\n : mdiArchiveOutline.path\n }\n size={0.8}\n />\n ),\n destructive: !topic.archived,\n disabled: Boolean(busyTopicId),\n disabledReason: busyTopicId ? 'Another topic is being saved' : undefined,\n onClick: () => void toggleArchived(topic),\n },\n ]\n\n return (\n <CardDisplay\n header=\"Topics\"\n help={pluginDocsHelp('emailCampaigns', { anchor: '#topics' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{\n action: scope ? (\n <Button variant=\"contained\" onClick={() => setDrawerOpen(true)}>\n {'New topic'}\n </Button>\n ) : null,\n }}\n >\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The streams your recipients can leave one at a time. Every ' +\n 'campaign belongs to one, and the preference page linked from ' +\n 'each email lets someone stop that stream without stopping ' +\n 'everything. Topics are shared across every site in this ' +\n 'organization, the same way your lists are.'}\n </Typography>\n {!scope ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'This site has no organization, so it has no topic list.'}\n </Typography>\n ) : (\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Topic'}</TableCell>\n <TableCell>{'What recipients are told they get'}</TableCell>\n <TableCell align=\"right\" />\n </TableRow>\n </TableHead>\n <TableBody>\n {topics.map((topic) => {\n const href = topicHref(topic.id)\n return (\n <TableRow\n key={topic.id}\n hover\n onClick={() => router.push(href)}\n sx={{ cursor: 'pointer' }}\n >\n <TableCell>\n <Stack\n useFlexGap\n direction=\"row\"\n spacing={1}\n sx={{ flexWrap: 'wrap', alignItems: 'center' }}\n >\n {/*\n The row's own handler would fire too and push the\n same route twice — one history entry per back press.\n */}\n <AppLink\n href={href}\n onClick={(event: { stopPropagation: () => void }) =>\n event.stopPropagation()\n }\n >\n {topic.name}\n </AppLink>\n {topic.archived ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n color=\"warning\"\n label=\"Retired\"\n />\n ) : null}\n {DECLARED_SUBSCRIPTION_TOPICS.some(\n (it) => it.id === topic.id,\n ) ? (\n <Typography\n variant=\"overline\"\n color=\"text.secondary\"\n >\n {'Built in'}\n </Typography>\n ) : null}\n </Stack>\n </TableCell>\n <TableCell>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {topic.description || '—'}\n </Typography>\n </TableCell>\n <TableCell\n align=\"right\"\n sx={{ width: 56 }}\n onClick={(event) => event.stopPropagation()}\n >\n <RowActionsMenu\n label={topic.name}\n items={rowActions(topic)}\n />\n </TableCell>\n </TableRow>\n )\n })}\n </TableBody>\n </ScrollTable>\n )}\n </Stack>\n <CreateArtifactDrawer\n open={drawerOpen}\n onClose={() => setDrawerOpen(false)}\n title=\"New email topic\"\n submitLabel=\"Create topic\"\n onSubmit={create}\n errorSlot={\n error ? (\n <Typography variant=\"body2\" color=\"error\" sx={{ mt: 2 }}>\n {'Could not create the topic. Try again.'}\n </Typography>\n ) : null\n }\n />\n </CardDisplay>\n )\n}\n\nexport default EmailTopicsCard\n"],"names":["createResourceUid","isSubscriptionTopicId","pluginDocsHelp","DECLARED_SUBSCRIPTION_TOPICS","EMAIL_TOPICS_COLLECTION","mdiArchiveArrowUpOutline","mdiArchiveOutline","mdiPencilOutline","AppLink","CardDisplay","MdiIcon","useConfirmationContext","RowActionsMenu","CreateArtifactDrawer","ScrollTable","useSnackbar","Button","Chip","Stack","TableBody","TableCell","TableHead","TableRow","Typography","doc","setDoc","useRouter","useCallback","useState","useFirestore","useEmailOrgMount","useOrgEmailTopics","writeEmailTopic","EmailTopicsCard","props","hostId","basePath","firestore","router","enqueueSnackbar","confirm","orgMount","topics","scope","orgId","drawerOpen","setDrawerOpen","error","setError","busyTopicId","setBusyTopicId","topicHref","topicId","create","values","name","String","displayName","trim","id","variant","description","archived","persist","push","caught","console","toggleArchived","topic","accepted","title","confirmationText","then","catch","rowActions","key","label","icon","path","size","href","destructive","disabled","Boolean","disabledReason","undefined","onClick","header","help","anchor","contentGutterX","contentGutterY","contentBordered","HeaderProps","action","spacing","color","align","map","hover","sx","cursor","useFlexGap","direction","flexWrap","alignItems","event","stopPropagation","some","it","width","items","open","onClose","submitLabel","onSubmit","errorSlot","mt"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SACEA,iBAAiB,EACjBC,qBAAqB,EACrBC,cAAc,EACdC,4BAA4B,QACvB,eAAc;AACrB,SAASC,uBAAuB,QAAQ,kCAA8B;AACtE,SACEC,wBAAwB,EACxBC,iBAAiB,EACjBC,gBAAgB,QACX,yBAAwB;AAC/B,SACEC,OAAO,EACPC,WAAW,EACXC,OAAO,EACPC,sBAAsB,QACjB,uBAAsB;AAC7B,OAAOC,oBAEA,6DAA4D;AACnE,SAASC,oBAAoB,QAAQ,6BAA4B;AACjE,SAASC,WAAW,QAAQ,yDAAwD;AACpF,SAASC,WAAW,QAAQ,8BAA6B;AACzD,SACEC,MAAM,EACNC,IAAI,EACJC,KAAK,EACLC,SAAS,EACTC,SAAS,EACTC,SAAS,EACTC,QAAQ,EACRC,UAAU,QACL,gBAAe;AACtB,SAASC,GAAG,EAAEC,MAAM,QAAQ,qBAAoB;AAChD,SAASC,SAAS,QAAQ,kBAAiB;AAC3C,SAASC,WAAW,EAAEC,QAAQ,QAAQ,QAAO;AAC7C,SAASC,YAAY,QAAQ,iCAAgC;AAC7D,SAASC,gBAAgB,QAAQ,uBAAmB;AACpD,SAASC,iBAAiB,EAAEC,eAAe,QAAQ,4BAAwB;AAS3E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsCC,GACD,OAAO,SAASC,gBAAgBC,KAA2B;IACzD,MAAM,EAAEC,MAAM,EAAEC,QAAQ,EAAE,GAAGF;IAC7B,MAAMG,YAAYR;IAClB,MAAMS,SAASZ;IACf,MAAM,EAAEa,eAAe,EAAE,GAAGxB;IAC5B,MAAM,EAAEyB,OAAO,EAAE,GAAG7B;IACpB,0EAA0E;IAC1E,yCAAyC;IACzC,MAAM8B,WAAWX;IACjB,MAAM,EAAEY,MAAM,EAAEC,KAAK,EAAE,GAAGZ,kBAAkBI,QAAQ;QAClDS,KAAK,EAAEH,4BAAAA,SAAUG,KAAK;IACxB;IAEA,MAAM,CAACC,YAAYC,cAAc,GAAGlB,SAAS;IAC7C,MAAM,CAACmB,OAAOC,SAAS,GAAGpB,SAAkB;IAC5C,MAAM,CAACqB,aAAaC,eAAe,GAAGtB,SAAS;IAE/C,uDAAuD,GACvD,MAAMuB,YAAYxB,YAChB,CAACyB,UAAoB,GAAGhB,SAAS,QAAQ,EAAEgB,SAAS,EACpD;QAAChB;KAAS;IAGZ,MAAMiB,SAAS1B,YACb,OAAO2B;;QACL,IAAI,CAACX,OAAO;QACZ,MAAMY,OAAOC,eAAOF,0BAAAA,OAAQG,WAAW,mBAAI,IAAIC,IAAI;QACnD,IAAI,CAACH,MAAM;QACX,oEAAoE;QACpE,+EAA+E;QAC/E,uEAAuE;QACvE,oEAAoE;QACpE,6DAA6D;QAC7D,MAAMI,KAAK3D;QACX,IAAI,CAACC,sBAAsB0D,KAAK;YAC9B,OAAO,KAAKpB,gBAAgB,8BAA8B;gBACxDqB,SAAS;YACX;QACF;QACA,IAAI;;YACF,MAAMnC,OACJD,IAAIa,WAAWM,KAAK,CAAC,EAAE,EAAEA,KAAK,CAAC,EAAE,EAAEvC,yBAAyBuD,KAC5D;gBACEJ;gBACAM,aAAaL,gBAAOF,0BAAAA,OAAQO,WAAW,oBAAI,IAAIH,IAAI;gBACnDI,UAAU;YACZ;YAEFhB,cAAc;YACdE,SAAS;YACTT,gBAAgB,eAAe;gBAAEqB,SAAS;gBAAWG,SAAS;YAAM;YACpE,uEAAuE;YACvEzB,OAAO0B,IAAI,CAACb,UAAUQ;QACxB,EAAE,OAAOM,QAAQ;YACfC,QAAQnB,KAAK,CAACkB;YACdjB,SAASiB;QACX;IACF,GACA;QAACtB;QAAON;QAAWE;QAAiBY;QAAWb;KAAO;IAGxD;;;;;;;;;;;GAWC,GACD,MAAM6B,iBAAiBxC,YACrB,OAAOyC;QACL,IAAI,CAACzB,SAASM,aAAa;QAC3B,IAAI,CAACmB,MAAMN,QAAQ,EAAE;YACnB,MAAMO,WAAW,MAAM7B,QAAQ;gBAC7B8B,OAAO,CAAC,QAAQ,EAAEF,MAAMb,IAAI,CAAC,EAAE,CAAC;gBAChCM,aACE,oEACA,qEACA,0DACA,oEACA;gBACFU,kBAAkB;YACpB,EACE,oEAAoE;YACpE,kEAAkE;aACjEC,IAAI,CAAC,IAAM,MACXC,KAAK,CAAC,IAAM;YACf,IAAI,CAACJ,UAAU;QACjB;QACAnB,eAAekB,MAAMT,EAAE;QACvB,IAAI;YACF,MAAM3B,gBAAgBK,WAAWM,OAAO;gBACtCgB,IAAIS,MAAMT,EAAE;gBACZJ,MAAMa,MAAMb,IAAI;gBAChBM,aAAaO,MAAMP,WAAW;gBAC9BC,UAAU,CAACM,MAAMN,QAAQ;YAC3B;YACAvB,gBAAgB6B,MAAMN,QAAQ,GAAG,mBAAmB,iBAAiB;gBACnEF,SAAS;gBACTG,SAAS;YACX;QACF,EAAE,OAAOE,QAAQ;YACfC,QAAQnB,KAAK,CAACkB;YACd1B,gBAAgB,yBAAyB;gBAAEqB,SAAS;YAAQ;QAC9D,SAAU;YACRV,eAAe;QACjB;IACF,GACA;QAACP;QAAOM;QAAaT;QAASH;QAAWE;KAAgB;IAG3D,MAAMmC,aAAa,CAACN,QAKQ;YAC1B;gBACEO,KAAK;gBACLC,OAAO;gBACPC,oBAAM,KAACnE;oBAAQoE,MAAMvE,iBAAiBuE,IAAI;oBAAEC,MAAM;;gBAClDC,MAAM7B,UAAUiB,MAAMT,EAAE;YAC1B;YACA;gBACEgB,KAAK;gBACLC,OAAOR,MAAMN,QAAQ,GAAG,YAAY;gBACpCe,oBACE,KAACnE;oBACCoE,MACEV,MAAMN,QAAQ,GACVzD,yBAAyByE,IAAI,GAC7BxE,kBAAkBwE,IAAI;oBAE5BC,MAAM;;gBAGVE,aAAa,CAACb,MAAMN,QAAQ;gBAC5BoB,UAAUC,QAAQlC;gBAClBmC,gBAAgBnC,cAAc,iCAAiCoC;gBAC/DC,SAAS,IAAM,KAAKnB,eAAeC;YACrC;SACD;IAED,qBACE,MAAC3D;QACC8E,QAAO;QACPC,MAAMtF,eAAe,kBAAkB;YAAEuF,QAAQ;QAAU;QAC3DC,cAAc;QACdC,cAAc;QACdC,iBAAgB;QAChBC,aAAa;YACXC,QAAQnD,sBACN,KAAC3B;gBAAO4C,SAAQ;gBAAY0B,SAAS,IAAMxC,cAAc;0BACtD;iBAED;QACN;;0BAEA,MAAC5B;gBAAM6E,SAAS;;kCACd,KAACxE;wBAAWqC,SAAQ;wBAAQoC,OAAM;kCAC/B,gEACC,kEACA,+DACA,6DACA;;oBAEH,CAACrD,sBACA,KAACpB;wBAAWqC,SAAQ;wBAAQoC,OAAM;kCAC/B;uCAGH,MAAClF;wBAAYiE,MAAK;;0CAChB,KAAC1D;0CACC,cAAA,MAACC;;sDACC,KAACF;sDAAW;;sDACZ,KAACA;sDAAW;;sDACZ,KAACA;4CAAU6E,OAAM;;;;;0CAGrB,KAAC9E;0CACEuB,OAAOwD,GAAG,CAAC,CAAC9B;oCACX,MAAMY,OAAO7B,UAAUiB,MAAMT,EAAE;oCAC/B,qBACE,MAACrC;wCAEC6E,KAAK;wCACLb,SAAS,IAAMhD,OAAO0B,IAAI,CAACgB;wCAC3BoB,IAAI;4CAAEC,QAAQ;wCAAU;;0DAExB,KAACjF;0DACC,cAAA,MAACF;oDACCoF,UAAU;oDACVC,WAAU;oDACVR,SAAS;oDACTK,IAAI;wDAAEI,UAAU;wDAAQC,YAAY;oDAAS;;sEAM7C,KAACjG;4DACCwE,MAAMA;4DACNM,SAAS,CAACoB,QACRA,MAAMC,eAAe;sEAGtBvC,MAAMb,IAAI;;wDAEZa,MAAMN,QAAQ,iBACb,KAAC7C;4DACC8D,MAAK;4DACLnB,SAAQ;4DACRoC,OAAM;4DACNpB,OAAM;6DAEN;wDACHzE,6BAA6ByG,IAAI,CAChC,CAACC,KAAOA,GAAGlD,EAAE,KAAKS,MAAMT,EAAE,kBAE1B,KAACpC;4DACCqC,SAAQ;4DACRoC,OAAM;sEAEL;6DAED;;;;0DAGR,KAAC5E;0DACC,cAAA,KAACG;oDAAWqC,SAAQ;oDAAQoC,OAAM;8DAC/B5B,MAAMP,WAAW,IAAI;;;0DAG1B,KAACzC;gDACC6E,OAAM;gDACNG,IAAI;oDAAEU,OAAO;gDAAG;gDAChBxB,SAAS,CAACoB,QAAUA,MAAMC,eAAe;0DAEzC,cAAA,KAAC/F;oDACCgE,OAAOR,MAAMb,IAAI;oDACjBwD,OAAOrC,WAAWN;;;;uCAxDjBA,MAAMT,EAAE;gCA6DnB;;;;;;0BAKR,KAAC9C;gBACCmG,MAAMnE;gBACNoE,SAAS,IAAMnE,cAAc;gBAC7BwB,OAAM;gBACN4C,aAAY;gBACZC,UAAU9D;gBACV+D,WACErE,sBACE,KAACxB;oBAAWqC,SAAQ;oBAAQoC,OAAM;oBAAQI,IAAI;wBAAEiB,IAAI;oBAAE;8BACnD;qBAED;;;;AAKd;AAEA,eAAepF,gBAAe"}
|
|
@@ -142,6 +142,10 @@ export function ListDetailCard(props) {
|
|
|
142
142
|
const headerActions = /*#__PURE__*/ _jsxs(Stack, {
|
|
143
143
|
direction: "row",
|
|
144
144
|
spacing: 1,
|
|
145
|
+
useFlexGap: true,
|
|
146
|
+
sx: {
|
|
147
|
+
flexWrap: 'wrap'
|
|
148
|
+
},
|
|
145
149
|
children: [
|
|
146
150
|
/*#__PURE__*/ _jsx(Button, _extends({
|
|
147
151
|
component: AppLink
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/list-detail-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\n/**\n * ONE AUDIENCE: who it is, how it is built, and every person on it.\n *\n * ## Why a route rather than an expanded row\n *\n * The membership used to unfold inside the list table, which cost three\n * things a merchant actually uses. A list was not linkable, so \"check the\n * Wholesale audience\" could not be sent to anybody. The back button walked out\n * of the whole surface rather than out of the list. And the list's own\n * settings had to live under a table they were not about.\n *\n * ## What it reads\n *\n * The list document — one document, whatever the audience's size — plus one\n * server aggregate for the subscriber total, plus the paged membership window\n * the table below draws. The membership is PII and there is one document per\n * subscriber, so it is read HERE, where somebody asked for it, and nowhere\n * that merely lists audiences.\n *\n * ## The rule is stated, not just flagged\n *\n * A dynamic list is a claim about who is on it, and \"Rule\" as a chip is not\n * that claim. The summary spells out every dimension the rule actually\n * carries, so the answer to \"why is this person not in the audience\" is on the\n * screen the question gets asked on.\n *\n * ## On the organization's page, the site it is worked AS\n *\n * The list is the organization's, but adding somebody to it is done as one\n * site — the enrollment and the consent it records are that site's — and the\n * Consent column reads what each person agreed to with one site's group. So\n * with no site in the URL the page asks which, defaulting to the site the\n * list's filters draw from, then the reader's pick this session, then the\n * org's only site. Nothing below the picker is built until there is an\n * answer: a consent column for no site would be a column about nobody.\n */\n\nimport { TransferResumeImport } from '@aglyn/aglyn/app-utils/transfer-resume-import'\nimport {\n consentGroupForHost,\n normalizeDynamicListRule,\n PageHeaderRecord,\n pluginDocsHelp,\n type ConsentGroup,\n} from '@aglyn/aglyn'\nimport { useTransferLauncher } from '@aglyn/aglyn/app-utils/transfer-launcher-context'\nimport {\n mdiPencilOutline,\n mdiTrayArrowDown,\n mdiTrayArrowUp,\n} from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport { Button, Chip, Stack, Typography } from '@mui/material'\nimport { collection, doc, getCountFromServer } from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useFirestore, useFirestoreDoc } from '@aglyn/tenant-feature-instance'\nimport { describeDynamicListRule } from './dynamic-list-rule-fields'\nimport {\n OrgSiteSelect,\n useEmailDataScope,\n useEmailOrgMount,\n} from './email-org-mount'\nimport { listMembersResourceKey } from '../transfer/email-transfer-catalog'\nimport ListMembersPanel, { type ListMembersFilter } from './list-members-panel'\n\nexport interface ListDetailCardProps {\n /** The site, or `null` on the organization's Emails page. */\n hostId: string | null\n /**\n * The controller this audience is being read AS, resolved by the page from\n * the org document it already holds — under a site.\n *\n * Passed through rather than resolved here: the group is a fact about the\n * ORG, one lookup serves every section, and a card that resolved its own\n * would be a second place for the answer to come from.\n */\n consentGroup?: ConsentGroup\n /**\n * The org document, on the organization's page, where the group can only be\n * resolved once the reader has said which site they are working as.\n */\n org?: Record<string, unknown>\n listId: string\n /** The emails hub URL, for the way back to the audiences list. */\n basePath: string\n}\n\nexport function ListDetailCard(props: ListDetailCardProps) {\n const { hostId, listId, basePath, org } = props\n const firestore = useFirestore()\n const orgMount = useEmailOrgMount()\n const { scope } = useEmailDataScope(hostId)\n\n const { data: list, status } = useFirestoreDoc<Record<string, any>>(\n () =>\n scope ? doc(firestore, scope[0], scope[1], 'lists', listId) : null,\n [firestore, scope, listId],\n )\n\n /*\n * The subscriber total, as a server aggregate.\n *\n * Not the length of the page below: a count beside a paged table is exactly\n * where a page length gets mistaken for a total. Taken once, and re-taken\n * only when a membership is added or removed — a figure that disagreed with\n * the table directly under it would read as the add having failed.\n */\n const [count, setCount] = useState<number | null>(null)\n const [membershipVersion, setMembershipVersion] = useState(0)\n useEffect(() => {\n if (!scope) return undefined\n let active = true\n void getCountFromServer(\n collection(firestore, scope[0], scope[1], 'lists', listId, 'members'),\n )\n .then((snapshot) => {\n if (active) setCount(snapshot.data().count)\n })\n .catch(() => {\n if (active) setCount(null)\n })\n return () => {\n active = false\n }\n }, [firestore, scope, listId, membershipVersion])\n\n const onMembershipChanged = useCallback(\n () => setMembershipVersion((version) => version + 1),\n [],\n )\n\n /*\n * THE SITE THIS AUDIENCE IS WORKED AS. Under a site, that site. On the\n * organization's page, the reader's choice — else the site the filters draw\n * from, else the session's pick, else the only site.\n */\n const [chosenHostId, setChosenHostId] = useState('')\n const listHostId = String(list?.['hostId'] ?? '')\n const enrollHostId: string =\n hostId ??\n (chosenHostId ||\n (orgMount?.hosts.some((site) => site.id === listHostId)\n ? listHostId\n : '') ||\n orgMount?.pickedHostId ||\n '')\n const consentGroup = useMemo<ConsentGroup | null>(\n () =>\n hostId != null\n ? (props.consentGroup ?? null)\n : enrollHostId\n ? consentGroupForHost(org, enrollHostId)\n : null,\n [hostId, props.consentGroup, enrollHostId, org],\n )\n\n /*\n * IMPORT AND EXPORT open the console's transfer wizard and export dialog\n * (AGL-3529) on this list — `email.list-members:<listId>`, one list at a\n * time — worked as the site chosen above. The wizard carries everything the import's\n * controls are: the preview, the screening, the statement of permission,\n * the staged and resumable write, the undo. Outside the console shell\n * there is no launcher, and the buttons are not drawn.\n */\n const transfer = useTransferLauncher()\n const [memberFilter, setMemberFilter] = useState<ListMembersFilter | null>(\n null,\n )\n const listName = String(list?.['name'] ?? '')\n // Each button only for whom the route takes it (AGL-3554), on the site the\n // list enrolls as — the workspace's verdict until one is chosen.\n const transferTarget = {\n resource: listMembersResourceKey(listId),\n scope: 'host' as const,\n hostId: enrollHostId || null,\n }\n const canExportMembers = Boolean(transfer?.can('export', transferTarget))\n const canImportMembers = Boolean(transfer?.can('import', transferTarget))\n\n const audiencesHref = `${basePath}/audiences`\n const headerActions = (\n <Stack direction=\"row\" spacing={1}>\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={audiencesHref}\n size=\"small\"\n color=\"primary\"\n >\n {'All audiences'}\n </Button>\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={`${audiencesHref}/${listId}/edit`}\n size=\"small\"\n color=\"primary\"\n variant=\"outlined\"\n startIcon={<MdiIcon path={mdiPencilOutline.path} size={0.8} />}\n >\n {'Edit list'}\n </Button>\n {transfer && canExportMembers ? (\n <Button\n size=\"small\"\n color=\"primary\"\n variant=\"outlined\"\n startIcon={<MdiIcon path={mdiTrayArrowDown.path} size={0.8} />}\n // Consent is read as a site; on the org page one has to be chosen.\n disabled={!enrollHostId}\n onClick={() =>\n transfer.openExport({\n resource: listMembersResourceKey(listId),\n scope: 'host',\n hostId: enrollHostId,\n ...(listName ? { title: `Export ${listName}` } : {}),\n ...(memberFilter ? { filter: memberFilter } : {}),\n })\n }\n >\n {'Export'}\n </Button>\n ) : null}\n {/* An import left unfinished, reopened where it stopped (AGL-3549). */}\n {transfer && enrollHostId ? (\n <TransferResumeImport\n target={transferTarget}\n {...(listName ? { title: `Import into ${listName}` } : {})}\n onFinished={onMembershipChanged}\n />\n ) : null}\n {transfer && canImportMembers ? (\n <Button\n size=\"small\"\n color=\"primary\"\n variant=\"contained\"\n startIcon={<MdiIcon path={mdiTrayArrowUp.path} size={0.8} />}\n // An import enrolls as a site; on the org page one has to be chosen.\n disabled={!enrollHostId}\n onClick={() =>\n transfer.openImport({\n resource: listMembersResourceKey(listId),\n scope: 'host',\n hostId: enrollHostId,\n ...(listName ? { title: `Import into ${listName}` } : {}),\n onFinished: onMembershipChanged,\n })\n }\n >\n {'Import'}\n </Button>\n ) : null}\n </Stack>\n )\n\n if (!list) {\n /*\n * Loading and MISSING are different answers, and the difference matters\n * more here than almost anywhere. Not \"no members\" — a list that cannot be\n * read is a different situation from an empty one, and rendering an empty\n * membership for the first is how somebody comes to believe an audience\n * they built has lost everybody. Reading `status` rather than the absence\n * of data is also what stops the refusal being flashed on every arrival,\n * before the org scope has even resolved.\n */\n const settled = Boolean(scope) && status !== 'loading'\n return (\n <CardDisplay\n header={'Audience'}\n help={pluginDocsHelp('emailCampaigns', { anchor: '#email-lists' })}\n contentGutterX\n contentGutterY\n HeaderProps={{ action: headerActions }}\n >\n <Typography variant=\"body2\" color=\"text.secondary\">\n {settled\n ? 'This list could not be loaded. It may have been deleted.'\n : 'Loading this audience…'}\n </Typography>\n </CardDisplay>\n )\n }\n\n const dynamic = list['kind'] === 'dynamic'\n /*\n * The filters, whichever kind of list this is.\n *\n * On a live list they are what the sweep evaluates. On a fixed one they are\n * a saved SEARCH — the same filters, used to find people to add rather than\n * to decide membership — so they are read and described either way, and only\n * the sentence about what happens next differs.\n */\n const rule = normalizeDynamicListRule(list['rule'])\n const hasFilters = rule.sources.length > 0\n const summary = hasFilters ? describeDynamicListRule(rule) : []\n\n /* The card, named so the page chrome above it is a plain list of\n what this surface publishes upward. */\n const card = (\n <CardDisplay\n header={'Audience'}\n help={pluginDocsHelp('emailCampaigns', { anchor: '#list-members' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{ action: headerActions }}\n >\n <Stack spacing={2}>\n <Stack\n direction=\"row\"\n spacing={1}\n useFlexGap\n sx={{ flexWrap: 'wrap', alignItems: 'center' }}\n >\n {dynamic ? (\n <Chip\n size=\"small\"\n color=\"primary\"\n variant=\"outlined\"\n label={\n list['lastEvaluatedAt']?.toDate\n ? `Rule · ${list['lastEvaluatedAt']\n .toDate()\n .toLocaleString()}`\n : 'Rule · not yet evaluated'\n }\n />\n ) : (\n <Chip size=\"small\" variant=\"outlined\" label=\"Fixed membership\" />\n )}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {count === null\n ? 'Counting subscribers…'\n : count === 1\n ? '1 subscriber'\n : `${count.toLocaleString()} subscribers`}\n </Typography>\n </Stack>\n\n {/*\n A live list's filters are stated HERE rather than only on the edit\n page: they are the answer to \"why is this person not in the\n audience\", which is a question asked while looking at the membership.\n A fixed list states them inside the find-people control instead,\n beside the button that acts on them.\n */}\n {dynamic && hasFilters ? (\n <Stack spacing={0.5}>\n {summary.map((clause) => (\n <Typography key={clause} variant=\"body2\" color=\"text.secondary\">\n {clause}\n </Typography>\n ))}\n </Stack>\n ) : null}\n\n {orgMount ? (\n <OrgSiteSelect\n mount={orgMount}\n label=\"Enroll as\"\n value={enrollHostId}\n onChange={setChosenHostId}\n helperText={\n 'People you add are enrolled as this site, and the Consent ' +\n 'column reads what they agreed to with it.'\n }\n />\n ) : null}\n\n {orgMount && !enrollHostId ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Choose the site to work this audience as. Whether someone may ' +\n 'be emailed depends on what they agreed to with a site, so the ' +\n 'members and their consent are read for one site at a time.'}\n </Typography>\n ) : scope && consentGroup ? (\n <ListMembersPanel\n key={enrollHostId}\n hostId={enrollHostId}\n consentGroup={consentGroup}\n scope={scope as readonly [string, string]}\n listId={listId}\n listName={String(list['name'] ?? '')}\n onMembershipChanged={onMembershipChanged}\n /*\n Offered only on a FIXED list. A live list's membership is the\n sweep's to decide, and hand-adding its own matches would write\n `via: 'manual'` copies of rows the sweep already owns — rows it\n then may not reconcile away when the person stops matching.\n */\n findRule={dynamic || !hasFilters ? null : rule}\n ruleSummary={summary}\n onFilterChange={setMemberFilter}\n />\n ) : null}\n </Stack>\n </CardDisplay>\n )\n\n return (\n <>\n {/* The page heading and the trail name the audience; this card is\n then free to say what it holds rather than repeating the title. */}\n <PageHeaderRecord title={String(list['name'] || listId)} />\n {card}\n </>\n )\n}\nListDetailCard.displayName = 'ListDetailCard'\n\nexport default ListDetailCard\n"],"names":["TransferResumeImport","consentGroupForHost","normalizeDynamicListRule","PageHeaderRecord","pluginDocsHelp","useTransferLauncher","mdiPencilOutline","mdiTrayArrowDown","mdiTrayArrowUp","AppLink","CardDisplay","MdiIcon","Button","Chip","Stack","Typography","collection","doc","getCountFromServer","useCallback","useEffect","useMemo","useState","useFirestore","useFirestoreDoc","describeDynamicListRule","OrgSiteSelect","useEmailDataScope","useEmailOrgMount","listMembersResourceKey","ListMembersPanel","ListDetailCard","props","list","hostId","listId","basePath","org","firestore","orgMount","scope","data","status","count","setCount","membershipVersion","setMembershipVersion","undefined","active","then","snapshot","catch","onMembershipChanged","version","chosenHostId","setChosenHostId","listHostId","String","enrollHostId","hosts","some","site","id","pickedHostId","consentGroup","transfer","memberFilter","setMemberFilter","listName","transferTarget","resource","canExportMembers","Boolean","can","canImportMembers","audiencesHref","headerActions","direction","spacing","component","componentVariant","nativeButton","href","size","color","variant","startIcon","path","disabled","onClick","openExport","title","filter","target","onFinished","openImport","settled","header","help","anchor","contentGutterX","contentGutterY","HeaderProps","action","dynamic","rule","hasFilters","sources","length","summary","card","contentBordered","useFlexGap","sx","flexWrap","alignItems","label","toDate","toLocaleString","map","clause","mount","value","onChange","helperText","findRule","ruleSummary","onFilterChange","displayName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/list-detail-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\n/**\n * ONE AUDIENCE: who it is, how it is built, and every person on it.\n *\n * ## Why a route rather than an expanded row\n *\n * The membership used to unfold inside the list table, which cost three\n * things a merchant actually uses. A list was not linkable, so \"check the\n * Wholesale audience\" could not be sent to anybody. The back button walked out\n * of the whole surface rather than out of the list. And the list's own\n * settings had to live under a table they were not about.\n *\n * ## What it reads\n *\n * The list document — one document, whatever the audience's size — plus one\n * server aggregate for the subscriber total, plus the paged membership window\n * the table below draws. The membership is PII and there is one document per\n * subscriber, so it is read HERE, where somebody asked for it, and nowhere\n * that merely lists audiences.\n *\n * ## The rule is stated, not just flagged\n *\n * A dynamic list is a claim about who is on it, and \"Rule\" as a chip is not\n * that claim. The summary spells out every dimension the rule actually\n * carries, so the answer to \"why is this person not in the audience\" is on the\n * screen the question gets asked on.\n *\n * ## On the organization's page, the site it is worked AS\n *\n * The list is the organization's, but adding somebody to it is done as one\n * site — the enrollment and the consent it records are that site's — and the\n * Consent column reads what each person agreed to with one site's group. So\n * with no site in the URL the page asks which, defaulting to the site the\n * list's filters draw from, then the reader's pick this session, then the\n * org's only site. Nothing below the picker is built until there is an\n * answer: a consent column for no site would be a column about nobody.\n */\n\nimport { TransferResumeImport } from '@aglyn/aglyn/app-utils/transfer-resume-import'\nimport {\n consentGroupForHost,\n normalizeDynamicListRule,\n PageHeaderRecord,\n pluginDocsHelp,\n type ConsentGroup,\n} from '@aglyn/aglyn'\nimport { useTransferLauncher } from '@aglyn/aglyn/app-utils/transfer-launcher-context'\nimport {\n mdiPencilOutline,\n mdiTrayArrowDown,\n mdiTrayArrowUp,\n} from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport { Button, Chip, Stack, Typography } from '@mui/material'\nimport { collection, doc, getCountFromServer } from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useFirestore, useFirestoreDoc } from '@aglyn/tenant-feature-instance'\nimport { describeDynamicListRule } from './dynamic-list-rule-fields'\nimport {\n OrgSiteSelect,\n useEmailDataScope,\n useEmailOrgMount,\n} from './email-org-mount'\nimport { listMembersResourceKey } from '../transfer/email-transfer-catalog'\nimport ListMembersPanel, { type ListMembersFilter } from './list-members-panel'\n\nexport interface ListDetailCardProps {\n /** The site, or `null` on the organization's Emails page. */\n hostId: string | null\n /**\n * The controller this audience is being read AS, resolved by the page from\n * the org document it already holds — under a site.\n *\n * Passed through rather than resolved here: the group is a fact about the\n * ORG, one lookup serves every section, and a card that resolved its own\n * would be a second place for the answer to come from.\n */\n consentGroup?: ConsentGroup\n /**\n * The org document, on the organization's page, where the group can only be\n * resolved once the reader has said which site they are working as.\n */\n org?: Record<string, unknown>\n listId: string\n /** The emails hub URL, for the way back to the audiences list. */\n basePath: string\n}\n\nexport function ListDetailCard(props: ListDetailCardProps) {\n const { hostId, listId, basePath, org } = props\n const firestore = useFirestore()\n const orgMount = useEmailOrgMount()\n const { scope } = useEmailDataScope(hostId)\n\n const { data: list, status } = useFirestoreDoc<Record<string, any>>(\n () =>\n scope ? doc(firestore, scope[0], scope[1], 'lists', listId) : null,\n [firestore, scope, listId],\n )\n\n /*\n * The subscriber total, as a server aggregate.\n *\n * Not the length of the page below: a count beside a paged table is exactly\n * where a page length gets mistaken for a total. Taken once, and re-taken\n * only when a membership is added or removed — a figure that disagreed with\n * the table directly under it would read as the add having failed.\n */\n const [count, setCount] = useState<number | null>(null)\n const [membershipVersion, setMembershipVersion] = useState(0)\n useEffect(() => {\n if (!scope) return undefined\n let active = true\n void getCountFromServer(\n collection(firestore, scope[0], scope[1], 'lists', listId, 'members'),\n )\n .then((snapshot) => {\n if (active) setCount(snapshot.data().count)\n })\n .catch(() => {\n if (active) setCount(null)\n })\n return () => {\n active = false\n }\n }, [firestore, scope, listId, membershipVersion])\n\n const onMembershipChanged = useCallback(\n () => setMembershipVersion((version) => version + 1),\n [],\n )\n\n /*\n * THE SITE THIS AUDIENCE IS WORKED AS. Under a site, that site. On the\n * organization's page, the reader's choice — else the site the filters draw\n * from, else the session's pick, else the only site.\n */\n const [chosenHostId, setChosenHostId] = useState('')\n const listHostId = String(list?.['hostId'] ?? '')\n const enrollHostId: string =\n hostId ??\n (chosenHostId ||\n (orgMount?.hosts.some((site) => site.id === listHostId)\n ? listHostId\n : '') ||\n orgMount?.pickedHostId ||\n '')\n const consentGroup = useMemo<ConsentGroup | null>(\n () =>\n hostId != null\n ? (props.consentGroup ?? null)\n : enrollHostId\n ? consentGroupForHost(org, enrollHostId)\n : null,\n [hostId, props.consentGroup, enrollHostId, org],\n )\n\n /*\n * IMPORT AND EXPORT open the console's transfer wizard and export dialog\n * (AGL-3529) on this list — `email.list-members:<listId>`, one list at a\n * time — worked as the site chosen above. The wizard carries everything the import's\n * controls are: the preview, the screening, the statement of permission,\n * the staged and resumable write, the undo. Outside the console shell\n * there is no launcher, and the buttons are not drawn.\n */\n const transfer = useTransferLauncher()\n const [memberFilter, setMemberFilter] = useState<ListMembersFilter | null>(\n null,\n )\n const listName = String(list?.['name'] ?? '')\n // Each button only for whom the route takes it (AGL-3554), on the site the\n // list enrolls as — the workspace's verdict until one is chosen.\n const transferTarget = {\n resource: listMembersResourceKey(listId),\n scope: 'host' as const,\n hostId: enrollHostId || null,\n }\n const canExportMembers = Boolean(transfer?.can('export', transferTarget))\n const canImportMembers = Boolean(transfer?.can('import', transferTarget))\n\n const audiencesHref = `${basePath}/audiences`\n const headerActions = (\n <Stack\n direction=\"row\"\n spacing={1}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={audiencesHref}\n size=\"small\"\n color=\"primary\"\n >\n {'All audiences'}\n </Button>\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={`${audiencesHref}/${listId}/edit`}\n size=\"small\"\n color=\"primary\"\n variant=\"outlined\"\n startIcon={<MdiIcon path={mdiPencilOutline.path} size={0.8} />}\n >\n {'Edit list'}\n </Button>\n {transfer && canExportMembers ? (\n <Button\n size=\"small\"\n color=\"primary\"\n variant=\"outlined\"\n startIcon={<MdiIcon path={mdiTrayArrowDown.path} size={0.8} />}\n // Consent is read as a site; on the org page one has to be chosen.\n disabled={!enrollHostId}\n onClick={() =>\n transfer.openExport({\n resource: listMembersResourceKey(listId),\n scope: 'host',\n hostId: enrollHostId,\n ...(listName ? { title: `Export ${listName}` } : {}),\n ...(memberFilter ? { filter: memberFilter } : {}),\n })\n }\n >\n {'Export'}\n </Button>\n ) : null}\n {/* An import left unfinished, reopened where it stopped (AGL-3549). */}\n {transfer && enrollHostId ? (\n <TransferResumeImport\n target={transferTarget}\n {...(listName ? { title: `Import into ${listName}` } : {})}\n onFinished={onMembershipChanged}\n />\n ) : null}\n {transfer && canImportMembers ? (\n <Button\n size=\"small\"\n color=\"primary\"\n variant=\"contained\"\n startIcon={<MdiIcon path={mdiTrayArrowUp.path} size={0.8} />}\n // An import enrolls as a site; on the org page one has to be chosen.\n disabled={!enrollHostId}\n onClick={() =>\n transfer.openImport({\n resource: listMembersResourceKey(listId),\n scope: 'host',\n hostId: enrollHostId,\n ...(listName ? { title: `Import into ${listName}` } : {}),\n onFinished: onMembershipChanged,\n })\n }\n >\n {'Import'}\n </Button>\n ) : null}\n </Stack>\n )\n\n if (!list) {\n /*\n * Loading and MISSING are different answers, and the difference matters\n * more here than almost anywhere. Not \"no members\" — a list that cannot be\n * read is a different situation from an empty one, and rendering an empty\n * membership for the first is how somebody comes to believe an audience\n * they built has lost everybody. Reading `status` rather than the absence\n * of data is also what stops the refusal being flashed on every arrival,\n * before the org scope has even resolved.\n */\n const settled = Boolean(scope) && status !== 'loading'\n return (\n <CardDisplay\n header={'Audience'}\n help={pluginDocsHelp('emailCampaigns', { anchor: '#email-lists' })}\n contentGutterX\n contentGutterY\n HeaderProps={{ action: headerActions }}\n >\n <Typography variant=\"body2\" color=\"text.secondary\">\n {settled\n ? 'This list could not be loaded. It may have been deleted.'\n : 'Loading this audience…'}\n </Typography>\n </CardDisplay>\n )\n }\n\n const dynamic = list['kind'] === 'dynamic'\n /*\n * The filters, whichever kind of list this is.\n *\n * On a live list they are what the sweep evaluates. On a fixed one they are\n * a saved SEARCH — the same filters, used to find people to add rather than\n * to decide membership — so they are read and described either way, and only\n * the sentence about what happens next differs.\n */\n const rule = normalizeDynamicListRule(list['rule'])\n const hasFilters = rule.sources.length > 0\n const summary = hasFilters ? describeDynamicListRule(rule) : []\n\n /* The card, named so the page chrome above it is a plain list of\n what this surface publishes upward. */\n const card = (\n <CardDisplay\n header={'Audience'}\n help={pluginDocsHelp('emailCampaigns', { anchor: '#list-members' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{ action: headerActions }}\n >\n <Stack spacing={2}>\n <Stack\n direction=\"row\"\n spacing={1}\n useFlexGap\n sx={{ flexWrap: 'wrap', alignItems: 'center' }}\n >\n {dynamic ? (\n <Chip\n size=\"small\"\n color=\"primary\"\n variant=\"outlined\"\n label={\n list['lastEvaluatedAt']?.toDate\n ? `Rule · ${list['lastEvaluatedAt']\n .toDate()\n .toLocaleString()}`\n : 'Rule · not yet evaluated'\n }\n />\n ) : (\n <Chip size=\"small\" variant=\"outlined\" label=\"Fixed membership\" />\n )}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {count === null\n ? 'Counting subscribers…'\n : count === 1\n ? '1 subscriber'\n : `${count.toLocaleString()} subscribers`}\n </Typography>\n </Stack>\n\n {/*\n A live list's filters are stated HERE rather than only on the edit\n page: they are the answer to \"why is this person not in the\n audience\", which is a question asked while looking at the membership.\n A fixed list states them inside the find-people control instead,\n beside the button that acts on them.\n */}\n {dynamic && hasFilters ? (\n <Stack spacing={0.5}>\n {summary.map((clause) => (\n <Typography key={clause} variant=\"body2\" color=\"text.secondary\">\n {clause}\n </Typography>\n ))}\n </Stack>\n ) : null}\n\n {orgMount ? (\n <OrgSiteSelect\n mount={orgMount}\n label=\"Enroll as\"\n value={enrollHostId}\n onChange={setChosenHostId}\n helperText={\n 'People you add are enrolled as this site, and the Consent ' +\n 'column reads what they agreed to with it.'\n }\n />\n ) : null}\n\n {orgMount && !enrollHostId ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Choose the site to work this audience as. Whether someone may ' +\n 'be emailed depends on what they agreed to with a site, so the ' +\n 'members and their consent are read for one site at a time.'}\n </Typography>\n ) : scope && consentGroup ? (\n <ListMembersPanel\n key={enrollHostId}\n hostId={enrollHostId}\n consentGroup={consentGroup}\n scope={scope as readonly [string, string]}\n listId={listId}\n listName={String(list['name'] ?? '')}\n onMembershipChanged={onMembershipChanged}\n /*\n Offered only on a FIXED list. A live list's membership is the\n sweep's to decide, and hand-adding its own matches would write\n `via: 'manual'` copies of rows the sweep already owns — rows it\n then may not reconcile away when the person stops matching.\n */\n findRule={dynamic || !hasFilters ? null : rule}\n ruleSummary={summary}\n onFilterChange={setMemberFilter}\n />\n ) : null}\n </Stack>\n </CardDisplay>\n )\n\n return (\n <>\n {/* The page heading and the trail name the audience; this card is\n then free to say what it holds rather than repeating the title. */}\n <PageHeaderRecord title={String(list['name'] || listId)} />\n {card}\n </>\n )\n}\nListDetailCard.displayName = 'ListDetailCard'\n\nexport default ListDetailCard\n"],"names":["TransferResumeImport","consentGroupForHost","normalizeDynamicListRule","PageHeaderRecord","pluginDocsHelp","useTransferLauncher","mdiPencilOutline","mdiTrayArrowDown","mdiTrayArrowUp","AppLink","CardDisplay","MdiIcon","Button","Chip","Stack","Typography","collection","doc","getCountFromServer","useCallback","useEffect","useMemo","useState","useFirestore","useFirestoreDoc","describeDynamicListRule","OrgSiteSelect","useEmailDataScope","useEmailOrgMount","listMembersResourceKey","ListMembersPanel","ListDetailCard","props","list","hostId","listId","basePath","org","firestore","orgMount","scope","data","status","count","setCount","membershipVersion","setMembershipVersion","undefined","active","then","snapshot","catch","onMembershipChanged","version","chosenHostId","setChosenHostId","listHostId","String","enrollHostId","hosts","some","site","id","pickedHostId","consentGroup","transfer","memberFilter","setMemberFilter","listName","transferTarget","resource","canExportMembers","Boolean","can","canImportMembers","audiencesHref","headerActions","direction","spacing","useFlexGap","sx","flexWrap","component","componentVariant","nativeButton","href","size","color","variant","startIcon","path","disabled","onClick","openExport","title","filter","target","onFinished","openImport","settled","header","help","anchor","contentGutterX","contentGutterY","HeaderProps","action","dynamic","rule","hasFilters","sources","length","summary","card","contentBordered","alignItems","label","toDate","toLocaleString","map","clause","mount","value","onChange","helperText","findRule","ruleSummary","onFilterChange","displayName"],"mappings":"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"}
|
|
@@ -74,6 +74,7 @@ import { collection, deleteDoc, doc } from "firebase/firestore";
|
|
|
74
74
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
75
75
|
import { useFirestore, useUser } from "@aglyn/tenant-feature-instance";
|
|
76
76
|
import { useListQuery } from "@aglyn/tenant-feature-instance/hooks/use-list-query";
|
|
77
|
+
import { useListColumnSort } from "@aglyn/shared-ui-jsx/hooks/use-list-column-sort";
|
|
77
78
|
import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
|
|
78
79
|
import { LIST_MEMBER_FILTER_HEADERS, LIST_MEMBER_FILTER_OPTIONS, LIST_MEMBER_QUERY } from "../constants/list-queries.js";
|
|
79
80
|
function consentLabel(member, group) {
|
|
@@ -134,6 +135,9 @@ function consentLabel(member, group) {
|
|
|
134
135
|
/** The How filter's choices are picked, so the panel shows a select. */ const MEMBER_SELECT_FIELDS = [
|
|
135
136
|
'via'
|
|
136
137
|
];
|
|
138
|
+
/** The page-sorted header, for its notice (AGL-3680). */ const MEMBER_SORT_HEADERS = {
|
|
139
|
+
consent: 'Consent'
|
|
140
|
+
};
|
|
137
141
|
/** How a row reads `via`: a row that predates the field was added by hand. */ const viaOf = (member)=>member.via === 'rule' ? 'rule' : 'manual';
|
|
138
142
|
/** Free text — a paste, a typed address, commas or newlines — to addresses. */ export function splitAddresses(value) {
|
|
139
143
|
return value.split(/[\s,;]+/).map((entry)=>entry.trim())// A pasted mail-client line is `Name <a@b.co>`; keep the address part.
|
|
@@ -155,7 +159,9 @@ export function ListMembersPanel(props) {
|
|
|
155
159
|
* `list-members.ts` still adopts predates it. Ordering on it would drop the
|
|
156
160
|
* oldest members from their own list, silently, which is the failure that
|
|
157
161
|
* turned an audience into a random sample (AGL-2501). The id is the one key
|
|
158
|
-
* every row has.
|
|
162
|
+
* every row has, so it stays the default. The Joined header asks for
|
|
163
|
+
* `addedAt` only since the writer stamps it on every row it touches and
|
|
164
|
+
* `backfill-list-member-sort-fields.mjs` on the rest (AGL-3680).
|
|
159
165
|
*
|
|
160
166
|
* Every filter and search word is a predicate on the same query
|
|
161
167
|
* (AGL-3321) — see `LIST_MEMBER_QUERY` — so a page is a page of the
|
|
@@ -163,12 +169,20 @@ export function ListMembersPanel(props) {
|
|
|
163
169
|
*/ const gridFilter = useListGridFilter({
|
|
164
170
|
selectFields: MEMBER_SELECT_FIELDS
|
|
165
171
|
});
|
|
172
|
+
/*
|
|
173
|
+
* EVERY HEADER SORTS (AGL-3680). Address, Name, Joined and How order the
|
|
174
|
+
* QUERY — `enrollListMember` stamps each on every row, null where there is
|
|
175
|
+
* none, so no order drops a member — each `alone`: under a filter or the
|
|
176
|
+
* search the list keeps its id order and says so. Consent is read per
|
|
177
|
+
* viewing consent group, so it sorts the page on screen.
|
|
178
|
+
*/ const [askedSort, setAskedSort] = useState(null);
|
|
166
179
|
const { rows: members, hasMore, page, setPage, pageSize, setPageSize, plan } = useListQuery({
|
|
167
180
|
collection: collection(firestore, scope[0], scope[1], 'lists', listId, 'members'),
|
|
168
181
|
declaration: LIST_MEMBER_QUERY,
|
|
169
182
|
request: {
|
|
170
183
|
clauses: gridFilter.clauses,
|
|
171
|
-
search: gridFilter.searchWords
|
|
184
|
+
search: gridFilter.searchWords,
|
|
185
|
+
sort: askedSort
|
|
172
186
|
},
|
|
173
187
|
deps: [
|
|
174
188
|
firestore,
|
|
@@ -178,6 +192,20 @@ export function ListMembersPanel(props) {
|
|
|
178
192
|
],
|
|
179
193
|
idField: '$id'
|
|
180
194
|
});
|
|
195
|
+
const pageSorts = useMemo(()=>({
|
|
196
|
+
consent: (row)=>consentLabel(row, consentGroup).label
|
|
197
|
+
}), [
|
|
198
|
+
consentGroup
|
|
199
|
+
]);
|
|
200
|
+
const columnSort = useListColumnSort({
|
|
201
|
+
sorts: LIST_MEMBER_QUERY.sorts,
|
|
202
|
+
sort: askedSort,
|
|
203
|
+
onSortChange: setAskedSort,
|
|
204
|
+
orderBy: plan.orderBy,
|
|
205
|
+
rows: members,
|
|
206
|
+
pageSorts,
|
|
207
|
+
headers: MEMBER_SORT_HEADERS
|
|
208
|
+
});
|
|
181
209
|
const filtering = gridFilter.clauses.length > 0 || gridFilter.searchWords.some((word)=>word.trim());
|
|
182
210
|
const { onFilterChange } = props;
|
|
183
211
|
const filterKey = filtering ? JSON.stringify(plan.filters) : '';
|
|
@@ -682,22 +710,25 @@ export function ListMembersPanel(props) {
|
|
|
682
710
|
}),
|
|
683
711
|
/*#__PURE__*/ _jsx(ListQueryNotices, {
|
|
684
712
|
refused: refusals,
|
|
685
|
-
notices:
|
|
713
|
+
notices: [
|
|
714
|
+
...plan.notices,
|
|
715
|
+
...columnSort.notices
|
|
716
|
+
]
|
|
686
717
|
}),
|
|
687
718
|
/*#__PURE__*/ _jsx(ListTable, {
|
|
688
719
|
"aria-label": `Members of ${listName}`,
|
|
689
|
-
rows:
|
|
720
|
+
rows: columnSort.rows,
|
|
721
|
+
columnSort: columnSort,
|
|
690
722
|
columns: listFilterGridColumns(columns, LIST_MEMBER_QUERY.fields, LIST_MEMBER_FILTER_OPTIONS, LIST_MEMBER_FILTER_HEADERS),
|
|
691
723
|
rowHeight: TABLE_ROW_HEIGHT,
|
|
692
724
|
// Paged by the footer below, so the grid must not also slice.
|
|
693
725
|
hideFooter: true,
|
|
694
|
-
// The panel and the
|
|
695
|
-
//
|
|
726
|
+
// The panel, the search and the headers go to the query (Consent
|
|
727
|
+
// sorts the page, and says so); the grid itself sorts nothing.
|
|
696
728
|
filterMode: "server",
|
|
697
729
|
filterModel: gridFilter.filterModel,
|
|
698
730
|
onFilterModelChange: gridFilter.onFilterModelChange,
|
|
699
731
|
quickFilter: true,
|
|
700
|
-
disableColumnSorting: true,
|
|
701
732
|
noRowsLabel: "No members match these filters"
|
|
702
733
|
}),
|
|
703
734
|
/*#__PURE__*/ _jsx(ListPagination, {
|