@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/list-members-panel.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 * Who is on one list, and the three things you can do about it.\n *\n * The audience card could create a list and delete a list. It could not show\n * you a single name on one. This panel is the rest of it: read the membership,\n * add somebody, take somebody off. The list's own settings — its name, and the\n * rule behind a dynamic one — belong to the edit route, because they are\n * facts about the LIST rather than about who is on it.\n *\n * ## It mounts only on a list's own page\n *\n * The audience detail route renders this for the ONE list being read. A\n * membership listener per row would open one per list on the audiences table,\n * on a surface where most visits are to read the list of lists — the\n * read-on-mount shape `emails-console-read-cost.spec.tsx` meters. Nothing here\n * reads anything until a list is opened, and the add form reads nothing until\n * an address is typed.\n *\n * ## Whether somebody may be ADDED is the server's answer, always\n *\n * Every sentence about an address being typed in is `email/list-members-preview`\n * answering from that person's actual record and both suppression lists. This\n * component decides none of it. A screen that decided for itself would be a\n * second copy of the rule on the one surface whose job is to tell the operator\n * the truth about what is about to happen — including, and especially, that\n * the product knows nothing about this person's permission.\n *\n * ## The membership table reads consent through the SEND-TIME reader\n *\n * The `Consent` column answers the same question `performCampaignSend` asks of\n * the same document, so it asks it with the same function —\n * {@link readMarketingBasis}, against the consent group this console is being\n * viewed as. A column that picked the fields out of the row itself is a second\n * reader of a stored shape, and the two only agree until the shape moves.\n *\n * ## Removing is not suppressing, and the screen says so\n *\n * Taking somebody off a list is the operator changing their mind about an\n * audience. It is not the person asking to stop being mailed, it does not stop\n * another list reaching them, and it writes nothing to the suppression list.\n * Those are different acts with different records, and a console that let them\n * blur is a console where \"remove\" is quietly relied on as an unsubscribe.\n */\n\nimport {\n readMarketingBasis,\n type ConsentGroup,\n type DynamicListRule,\n} from '@aglyn/aglyn'\nimport { mdiAccountRemoveOutline } from '@aglyn/shared-data-mdi'\nimport { MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport ListFilterChips from '@aglyn/shared-ui-jsx/components/list-filter-chips.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport ListQueryNotices, {\n listQueryRefusals,\n} from '@aglyn/shared-ui-jsx/components/list-query-notices.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { listFilterGridColumns } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useListGridFilter } from '@aglyn/shared-ui-jsx/hooks/use-list-grid-filter'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Alert,\n Box,\n Button,\n Checkbox,\n Chip,\n FormControlLabel,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport { collection, deleteDoc, doc } from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useFirestore, useUser } from '@aglyn/tenant-feature-instance'\nimport { useListQuery } from '@aglyn/tenant-feature-instance/hooks/use-list-query'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport {\n LIST_MEMBER_FILTER_HEADERS,\n LIST_MEMBER_FILTER_OPTIONS,\n LIST_MEMBER_QUERY,\n} from '../constants/list-queries'\n\nexport interface ListMembersPanelProps {\n hostId: string\n /**\n * The controller this membership is being read AS — the declared group of\n * sites that are one sender, or this site alone.\n *\n * Required and undefaulted, for the reason {@link readMarketingBasis} makes\n * it required: a basis runs to a brand, so a surface that has not said which\n * brand is asking cannot report one. Defaulting to the group of one here\n * would compile and would then answer a declared group's questions with a\n * single site's facts — reporting a sibling's refusal as no record at all,\n * and a pooled grant as another brand's.\n */\n consentGroup: ConsentGroup\n /** `['orgs', orgId]` — the resolved org scope the card already holds. */\n scope: readonly [string, string]\n listId: string\n listName: string\n /**\n * Called after a membership is added or removed.\n *\n * The audience page's subscriber total is a server aggregate, taken once.\n * Left alone it would go on reporting the figure from before the add — a\n * stale total directly above a live table that disagrees with it, which\n * reads as the add having failed. The page re-takes the aggregate on this\n * signal and at no other time, so nothing is re-counted while somebody is\n * only reading.\n */\n onMembershipChanged?: () => void\n /**\n * The list's saved filters, when it has any and its membership is FIXED.\n *\n * Present, the panel offers to find the people they select and add them —\n * the same act as typing addresses, with the search doing the typing. Absent\n * (a live list, or one with no filters saved), the add form is the address\n * box alone: a live list's membership is the sweep's to decide, and offering\n * to add its own matches by hand would produce hand-added copies of rows the\n * sweep already owns.\n */\n findRule?: DynamicListRule | null\n /** The same filters in sentences, so the button says what it will find. */\n ruleSummary?: readonly string[]\n /**\n * Told what the table is filtered to — `null` when it is not — so the\n * card's Export can offer \"the current filter\": the predicates this\n * table's own query is asking, which the export reads with the same query.\n */\n onFilterChange?: (filter: ListMembersFilter | null) => void\n}\n\n/** The member table's filter, as the export dialog offers it. */\nexport interface ListMembersFilter {\n label: string\n value: { filters: ReadonlyArray<{ path: string; op: string; value: unknown }> }\n}\n\n/**\n * One member row, as stored.\n *\n * The consent fields are deliberately NOT enumerated here. They live one level\n * down, in an entry per site under `marketingConsentByHost`, and a row type\n * that named them at the top would be an invitation to read them there — which\n * is exactly what this table did while `enrollListMember` wrote them nested,\n * so every membership on every list reported \"No basis on record\" while the\n * send path mailed the same people on a basis it could see perfectly well. The\n * index signature is what {@link readMarketingBasis} takes; it is the only\n * reader of those fields on this surface.\n */\ninterface MemberRow {\n $id: string\n email?: string\n name?: string\n source?: string\n via?: 'manual' | 'rule'\n addedAt?: { toDate?: () => Date }\n [field: string]: unknown\n}\n\n/** What `email/list-members-preview` answers for one address. */\ninterface AddressVerdict {\n input: string\n email: string | null\n refusal: string | null\n requiresAttestation: boolean\n summary: string\n}\n\ninterface Preview {\n verdicts: AddressVerdict[]\n optedIn: number\n needAttestation: number\n refused: number\n}\n\n/** What a filter search found, as `email/list-rule-preview` reports it. */\ninterface FoundPeople extends Preview {\n /** People the filters matched — NOT the length of `emails`. */\n matched: number\n /** `emails` is one batch of a larger match. */\n truncated: boolean\n /** False when the scan hit its budget, so `matched` is itself a floor. */\n complete: boolean\n emails: string[]\n}\n\n/** What one address's outcome was, after the add. */\ninterface AddResult {\n input: string\n email: string | null\n enrolled: boolean\n error?: string\n}\n\n/**\n * How the basis reads on screen, for the group this console is viewed as.\n *\n * ## Read through `readMarketingBasis`, never off the row\n *\n * This picked `marketingConsent`, `marketingConsentBasis` and\n * `marketingConsentAtMs` off the top of the membership document, which is\n * where `enrollListMember` used to put them. They now live in an entry per\n * site under `marketingConsentByHost`, because a basis runs to the brand it\n * was given to rather than to the account that holds the row. Nothing here\n * moved with them, so every top-level read answered `undefined` and every\n * member of every list — including people carrying a perfectly good attested\n * basis — was reported as having no basis on record, on the one screen an\n * operator consults before mailing them. `performCampaignSend` reads the same\n * documents through the function below and mailed them, correctly, at the same\n * time.\n *\n * The fix is not a second field name. It is that the send path's reader is the\n * only reader: a surface that parses the stored shape itself agrees with the\n * sender only until the shape moves, and it moves without anybody thinking to\n * look here.\n *\n * ## Five states, because the model carries five\n *\n * An attestation and an opt-in are not the same fact — one is a person's own\n * decision, the other a claim an account made on their behalf — and a table\n * rendering both as a tick is the conflation `list-members.ts` stores the\n * basis to prevent. The three that are not a grant are not one state either.\n * Absence is not refusal, and neither of them is \"opted in, to somebody else\n * in this account\": that third one is a person the sender deliberately leaves\n * alone, and an operator who cannot see why reads it as the product having\n * lost their subscriber.\n */\ntype ConsentKey =\n | 'opted-out'\n | 'attested'\n | 'opted-in'\n | 'other-site'\n | 'no-site'\n | 'none'\n\nfunction consentLabel(\n member: MemberRow,\n group: ConsentGroup,\n): { key: ConsentKey; label: string; color: 'default' | 'warning' | 'error' } {\n const record = readMarketingBasis(member as Record<string, unknown>, group)\n const when = record.basisAtMs\n ? ` · ${new Date(record.basisAtMs).toLocaleDateString()}`\n : ''\n if (record.basis === 'declined') {\n return { key: 'opted-out', label: `Opted out${when}`, color: 'error' }\n }\n if (record.basis === 'granted') {\n /*\n * `assertedBy` rather than the stored basis field, because there are two\n * ways a record says an operator asserted it — a membership's\n * `marketingConsentBasis`, and the provenance object a backfill writes —\n * and the reader has already resolved both into this one answer.\n */\n return record.assertedBy === 'operator'\n ? { key: 'attested', label: `Attested by your team${when}`, color: 'warning' }\n : { key: 'opted-in', label: `Opted in${when}`, color: 'default' }\n }\n /*\n * A grant this site may not use. Reported rather than folded into \"no\n * basis\" because the two are different situations with different answers:\n * nobody has ever asked this person, versus somebody asked them on behalf\n * of another brand in this account and this brand may not borrow it.\n */\n if (record.otherGrant === 'other-host') {\n return { key: 'other-site', label: 'Opted in to another site', color: 'warning' }\n }\n if (record.otherGrant === 'unscoped') {\n return { key: 'no-site', label: 'Opted in — no site recorded', color: 'warning' }\n }\n return { key: 'none', label: 'No basis on record', color: 'default' }\n}\n\n/** How a member got here, by the words the column draws. */\nconst VIA_LABELS: Readonly<Record<'rule' | 'manual', string>> = {\n rule: 'Rule',\n manual: 'Added',\n}\n/** The How filter's choices are picked, so the panel shows a select. */\nconst MEMBER_SELECT_FIELDS = ['via']\n\n/** How a row reads `via`: a row that predates the field was added by hand. */\nconst viaOf = (member: MemberRow): 'rule' | 'manual' =>\n member.via === 'rule' ? 'rule' : 'manual'\n\n/** Free text — a paste, a typed address, commas or newlines — to addresses. */\nexport function splitAddresses(value: string): string[] {\n return value\n .split(/[\\s,;]+/)\n .map((entry) => entry.trim())\n // A pasted mail-client line is `Name <a@b.co>`; keep the address part.\n .map((entry) => entry.replace(/^.*<|>.*$/g, ''))\n .filter(Boolean)\n}\n\nexport function ListMembersPanel(props: ListMembersPanelProps) {\n const {\n hostId,\n consentGroup,\n scope,\n listId,\n listName,\n onMembershipChanged,\n findRule,\n ruleSummary,\n } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n\n /*\n * Ordered by DOCUMENT ID and paged, not capped and re-sorted here.\n *\n * `__name__` rather than `addedAt`, which is the column a reader would\n * assume: `orderBy` FILTERS as well as sorts, so a document missing the\n * field is not in the result at all — and `addedAt` is stamped only when a\n * membership is CREATED, so every row written under the two legacy ids\n * `list-members.ts` still adopts predates it. Ordering on it would drop the\n * oldest members from their own list, silently, which is the failure that\n * turned an audience into a random sample (AGL-2501). The id is the one key\n * every row has.\n *\n * Every filter and search word is a predicate on the same query\n * (AGL-3321) — see `LIST_MEMBER_QUERY` — so a page is a page of the\n * matches.\n */\n const gridFilter = useListGridFilter({ selectFields: MEMBER_SELECT_FIELDS })\n const {\n rows: members,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n plan,\n } = useListQuery<MemberRow>({\n collection: collection(firestore, scope[0], scope[1], 'lists', listId, 'members'),\n declaration: LIST_MEMBER_QUERY,\n request: { clauses: gridFilter.clauses, search: gridFilter.searchWords },\n deps: [firestore, scope[0], scope[1], listId],\n idField: '$id',\n })\n const filtering =\n gridFilter.clauses.length > 0 || gridFilter.searchWords.some((word) => word.trim())\n const { onFilterChange } = props\n const filterKey = filtering ? JSON.stringify(plan.filters) : ''\n useEffect(() => {\n if (!onFilterChange) return\n onFilterChange(\n filterKey\n ? {\n label: 'Members matching the table’s filters',\n value: { filters: JSON.parse(filterKey) },\n }\n : null,\n )\n }, [filterKey, onFilterChange])\n const refusals = useMemo(\n () =>\n listQueryRefusals(plan.refused, {\n fields: LIST_MEMBER_QUERY.fields,\n headers: LIST_MEMBER_FILTER_HEADERS,\n options: LIST_MEMBER_FILTER_OPTIONS,\n }),\n [plan.refused],\n )\n\n const [addInput, setAddInput] = useState('')\n const [addName, setAddName] = useState('')\n const [preview, setPreview] = useState<Preview | null>(null)\n const [attested, setAttested] = useState(false)\n const [results, setResults] = useState<AddResult[] | null>(null)\n const [busy, setBusy] = useState(false)\n /*\n * The addresses a SEARCH found, when the operator ran one.\n *\n * A second way to fill the same candidate set, not a second way to add. Once\n * this holds addresses, everything below it — the consent readout, the\n * attestation, the Add button — is the code the typed path already used, so\n * there is exactly one place in this component where somebody is put on a\n * list and exactly one gate in front of it.\n */\n const [found, setFound] = useState<FoundPeople | null>(null)\n\n const addresses = found ? found.emails : splitAddresses(addInput)\n\n /*\n * Any edit to the box invalidates the answer.\n *\n * A preview belongs to the exact addresses it was run for. Leaving a stale\n * one on screen would show a count, and an attestation checkbox, for a set\n * the operator has since changed — so the number they stand behind would\n * not be the number that acts. Typing also abandons a search: the two are\n * alternative ways of naming a candidate set, and holding both at once\n * would leave the Add button acting on whichever the code happened to\n * prefer.\n */\n useEffect(() => {\n setPreview(null)\n setAttested(false)\n setResults(null)\n setFound(null)\n }, [addInput])\n\n const post = useCallback(\n async (route: string, body: Record<string, unknown>) => {\n const response = await authorizedFetch(user, `/api/email/${route}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, listId, ...body }),\n })\n const payload = await response.json().catch(() => ({}))\n return { ok: response.ok, payload }\n },\n [user, hostId, listId],\n )\n\n const handlePreview = useCallback(async () => {\n if (busy || !addresses.length) return\n setBusy(true)\n try {\n const { ok, payload } = await post('list-members-preview', {\n emails: addresses,\n })\n if (!ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The addresses could not be checked.',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n setPreview(payload as Preview)\n } catch {\n enqueueSnackbar('The addresses could not be checked.', {\n variant: 'warning',\n allowDuplicate: true,\n })\n } finally {\n setBusy(false)\n }\n }, [busy, addresses, post, enqueueSnackbar])\n\n const handleFind = useCallback(async () => {\n if (busy || !findRule) return\n setBusy(true)\n try {\n const { ok, payload } = await post('list-rule-preview', {\n rule: findRule,\n })\n if (!ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The audience could not be worked out.',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n /*\n * The verdicts come back from the SAME resolution the typed path runs,\n * so what lands in `preview` here is the same object the consent\n * readout below already knows how to draw. A search that returned its\n * own shaped summary would be a second consent readout, and the second\n * one is the one nobody reviews.\n */\n setFound(payload as FoundPeople)\n setPreview(payload as Preview)\n setAttested(false)\n setResults(null)\n if (!Number(payload?.matched ?? 0)) {\n enqueueSnackbar('These filters match nobody', {\n variant: 'info',\n persist: false,\n })\n }\n } catch {\n enqueueSnackbar('The audience could not be worked out.', {\n variant: 'warning',\n allowDuplicate: true,\n })\n } finally {\n setBusy(false)\n }\n }, [busy, findRule, post, enqueueSnackbar])\n\n const handleAdd = useCallback(async () => {\n if (busy || !preview) return\n setBusy(true)\n try {\n const { ok, payload } = await post('list-members-add', {\n emails: addresses,\n ...(addresses.length === 1 && addName.trim()\n ? { name: addName.trim() }\n : {}),\n // The operator's assertion, not a basis they named: the server\n // derives a pass-through from each person's own record, and this flag\n // can only ever add the attributable kind.\n attestConsent: attested,\n })\n if (!ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'Nobody was added to the list',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n const added = Number(payload?.added ?? 0)\n setResults((payload?.results ?? []) as AddResult[])\n setPreview(null)\n if (added) {\n setAddInput('')\n setAddName('')\n setFound(null)\n onMembershipChanged?.()\n }\n enqueueSnackbar(\n added === 1 ? 'One person added' : `${added} people added`,\n { variant: added ? 'success' : 'warning', persist: false },\n )\n } catch {\n enqueueSnackbar('Nobody was added to the list', {\n variant: 'warning',\n allowDuplicate: true,\n })\n } finally {\n setBusy(false)\n }\n }, [\n busy,\n preview,\n addresses,\n addName,\n attested,\n post,\n enqueueSnackbar,\n onMembershipChanged,\n ])\n\n const handleRemove = useCallback(\n async (member: MemberRow) => {\n const accepted = await confirm({\n title: 'Remove from this list?',\n description:\n `${member.email ?? 'This person'} stops being part of \"${listName}\" ` +\n 'and campaigns to this list stop reaching them. This is NOT an ' +\n 'unsubscribe: it does not add them to your suppression list, and ' +\n 'any other list they are on still reaches them. If they asked to ' +\n 'stop hearing from you, suppress the address instead.',\n confirmationText: 'Remove',\n confirmationButtonProps: { color: 'error' },\n })\n // `confirm` resolves with no value and REJECTS on cancel, so gating on\n // the resolved value alone would make this always proceed (AGL-950).\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n try {\n await deleteDoc(\n doc(\n firestore,\n scope[0],\n scope[1],\n 'lists',\n listId,\n 'members',\n member.$id,\n ),\n )\n onMembershipChanged?.()\n enqueueSnackbar('Removed from the list', {\n variant: 'success',\n persist: false,\n })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('They were not removed', { variant: 'error' })\n }\n },\n [\n confirm,\n listName,\n firestore,\n scope,\n listId,\n enqueueSnackbar,\n onMembershipChanged,\n ],\n )\n\n const enrollable = preview\n ? preview.optedIn + preview.needAttestation\n : 0\n\n const columns: GridColDef[] = [\n {\n field: 'email',\n headerName: 'Address',\n flex: 1,\n minWidth: 220,\n valueGetter: (_value, row) => row.email ?? '—',\n },\n {\n field: 'name',\n headerName: 'Name',\n flex: 1,\n minWidth: 160,\n valueGetter: (_value, row) => row.name ?? '—',\n },\n {\n field: 'addedAt',\n headerName: 'Joined',\n width: 120,\n // Sorted on the instant, drawn as a local date.\n valueGetter: (_value, row) => row.addedAt?.toDate?.()?.getTime?.() ?? 0,\n renderCell: ({ row }) =>\n row.addedAt?.toDate ? row.addedAt.toDate().toLocaleDateString() : '—',\n },\n {\n field: 'via',\n headerName: 'How',\n width: 200,\n valueGetter: (_value, row) => viaOf(row),\n /*\n `via` says whether a rule put them here or a person did, which is what\n decides whether they LEAVE on their own: the materializer reconciles its\n own rows away and never touches a manual one. `source` is the finer\n provenance underneath it.\n */\n renderCell: ({ row }) => (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center', minWidth: 0 }}>\n <Chip size=\"small\" variant=\"outlined\" label={VIA_LABELS[viaOf(row)]} />\n {row.source ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {row.source}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n {\n field: 'consent',\n headerName: 'Consent',\n width: 190,\n valueGetter: (_value, row) => consentLabel(row, consentGroup).label,\n renderCell: ({ row }) => {\n const consent = consentLabel(row, consentGroup)\n return (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n color={consent.color}\n label={consent.label}\n />\n )\n },\n },\n /*\n A SUBSCRIBER HAS NO PAGE of their own here, so the row opens nothing —\n but taking somebody off an audience is the one act performed on one, and\n it belongs in the trailing cluster with every other list's actions rather\n than as a red text button sitting in the row.\n */\n listActionsColumn(\n (row) => (\n <ListRowActions\n label={String(row.email ?? row.$id)}\n items={[\n {\n key: 'remove',\n label: 'Remove from this list',\n icon: <MdiIcon path={mdiAccountRemoveOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleRemove(row),\n },\n ]}\n />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <Stack spacing={2} sx={{ py: 2 }}>\n {/*\n FINDING people, for a list whose membership is fixed.\n The filters are the same ones a live list is swept by; here they run\n once, on request, and what they find is offered for adding rather than\n enrolled. The count and the consent readout arrive together, because\n the second is the thing that decides how much of the first can act.\n */}\n {findRule ? (\n <Stack\n spacing={1}\n sx={{ border: 1, borderColor: 'divider', borderRadius: 1, p: 1.5 }}\n >\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Find people'}\n </Typography>\n {(ruleSummary ?? []).map((clause) => (\n <Typography key={clause} variant=\"body2\">\n {clause}\n </Typography>\n ))}\n <Box>\n <Button\n size=\"small\"\n variant=\"outlined\"\n disabled={busy}\n onClick={() => void handleFind()}\n >\n {busy ? 'Searching…' : 'Find matching people'}\n </Button>\n </Box>\n {found ? (\n <Alert severity={found.matched ? 'info' : 'warning'}>\n {(found.matched === 1\n ? '1 person matches'\n : `${found.matched.toLocaleString()} people match`) +\n (found.truncated\n ? ` — this batch covers the first ${found.emails.length}. ` +\n 'Add them, then search again for the rest.'\n : '') +\n (found.complete\n ? ''\n : ' The search stopped at its read budget, so this is at ' +\n 'least that many rather than exactly.')}\n </Alert>\n ) : null}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Whoever you add stays on this list. It does not keep growing — ' +\n 'change the membership to live on the edit page if you want ' +\n 'new matches enrolled automatically.'}\n </Typography>\n </Stack>\n ) : null}\n\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Add someone by typing their address, or paste a column of them. ' +\n 'Every address is checked against their consent record and both ' +\n 'suppression lists before anyone is added.'}\n </Typography>\n\n <Stack direction=\"row\" spacing={1} useFlexGap sx={{ flexWrap: 'wrap' }}>\n <TextField\n size=\"small\"\n label=\"Email addresses\"\n placeholder=\"someone@example.com\"\n multiline\n minRows={1}\n maxRows={6}\n value={addInput}\n onChange={(event) => setAddInput(event.target.value)}\n helperText={\n addresses.length > 1\n ? `${addresses.length} addresses`\n : 'One address, or paste many'\n }\n sx={{ flexGrow: 1, minWidth: 260 }}\n />\n {addresses.length > 1 ? null : (\n <TextField\n size=\"small\"\n label=\"Name\"\n value={addName}\n onChange={(event) => setAddName(event.target.value)}\n sx={{ minWidth: 180 }}\n />\n )}\n <Box>\n <Button\n size=\"small\"\n variant=\"outlined\"\n disabled={busy || !addresses.length || Boolean(preview)}\n onClick={() => void handlePreview()}\n >\n {busy ? 'Checking…' : 'Check'}\n </Button>\n </Box>\n </Stack>\n\n {/*\n The count, in front of the operator, BEFORE the attestation control\n exists. One assertion covers the batch, so the number it covers has to\n be on screen when it is given — an attestation offered above an\n unexamined paste is a signature on a blank page.\n */}\n {preview ? (\n <Stack spacing={1}>\n <Alert severity={enrollable ? 'info' : 'warning'}>\n {[\n preview.optedIn\n ? `${preview.optedIn} already opted in`\n : '',\n preview.needAttestation\n ? `${preview.needAttestation} with no opt-in on record`\n : '',\n preview.refused\n ? `${preview.refused} cannot be added at all`\n : '',\n ]\n .filter(Boolean)\n .join(' · ')}\n </Alert>\n {/*\n The HARD refusals only. An address that merely needs the\n attestation is not a refusal — listing it here would put the very\n people the checkbox below is about under a heading that says they\n cannot be added.\n */}\n {preview.verdicts\n .filter((verdict) => verdict.refusal)\n .map((verdict) => (\n <Typography\n key={verdict.input}\n variant=\"caption\"\n color=\"text.secondary\"\n >\n {`${verdict.input} — ${verdict.summary}`}\n </Typography>\n ))}\n {preview.needAttestation ? (\n <FormControlLabel\n control={\n <Checkbox\n checked={attested}\n onChange={(event) => setAttested(event.target.checked)}\n />\n }\n label={\n <Typography variant=\"body2\">\n {`I have permission to send marketing email to the ` +\n `${preview.needAttestation} ` +\n `${preview.needAttestation === 1 ? 'person' : 'people'} ` +\n 'above with no opt-in on record. This is recorded against ' +\n 'my account, with the date.'}\n </Typography>\n }\n />\n ) : null}\n <Box>\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={\n busy ||\n !enrollable ||\n (preview.needAttestation > 0 && !attested)\n }\n onClick={() => void handleAdd()}\n >\n {busy ? 'Adding…' : `Add ${enrollable}`}\n </Button>\n </Box>\n </Stack>\n ) : null}\n\n {/*\n What actually happened, per address. A batch where one address was\n suppressed and forty were fine is not a failure, and a bare count would\n leave the operator to work out which of their lines is missing.\n */}\n {results?.length ? (\n <Stack spacing={0.5}>\n {results\n .filter((result) => !result.enrolled)\n .map((result) => (\n <Typography\n key={result.input}\n variant=\"caption\"\n color=\"text.secondary\"\n >\n {`${result.input} — not added. ${result.error ?? ''}`}\n </Typography>\n ))}\n </Stack>\n ) : null}\n\n {members.length === 0 && !hasMore && !filtering ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Nobody is on this list yet.'}\n </Typography>\n ) : (\n <>\n <ListFilterChips\n fields={LIST_MEMBER_QUERY.fields}\n headers={LIST_MEMBER_FILTER_HEADERS}\n clauses={gridFilter.clauses}\n onChange={gridFilter.setClauses}\n options={LIST_MEMBER_FILTER_OPTIONS}\n />\n <ListQueryNotices refused={refusals} notices={plan.notices} />\n <ListTable\n aria-label={`Members of ${listName}`}\n rows={members}\n columns={listFilterGridColumns(\n columns,\n LIST_MEMBER_QUERY.fields,\n LIST_MEMBER_FILTER_OPTIONS,\n LIST_MEMBER_FILTER_HEADERS,\n )}\n rowHeight={TABLE_ROW_HEIGHT}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n // The panel and the search go to the query; the grid neither\n // filters nor sorts the page it holds.\n filterMode=\"server\"\n filterModel={gridFilter.filterModel}\n onFilterModelChange={gridFilter.onFilterModelChange}\n quickFilter\n disableColumnSorting\n noRowsLabel=\"No members match these filters\"\n />\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={members.length}\n hasMore={hasMore}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n </>\n )}\n\n {/*\n What the column MEANS, and what to do about the one state an operator\n can act on. \"No basis on record\" is a fact about this site's records,\n not a refusal and not a dead end — the remedy is the add form above,\n which merges onto the membership that is already here rather than\n creating a second one. Whether a basis-less member is actually mailed\n is the org's consent policy to decide and is deliberately not claimed\n here: this surface does not hold that setting, and a caption that\n guessed it would be wrong for every org on the other mode.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'\"No basis on record\" means nobody has recorded this person\\'s ' +\n 'permission for this site — it is not a refusal. To record one, put ' +\n 'their address in the box above and state that you have their ' +\n 'permission; the statement is kept against your account, with the ' +\n 'date.'}\n </Typography>\n\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Removing someone from a list is not an unsubscribe. It does not add ' +\n 'them to your suppression list, and it does not stop any other list ' +\n 'reaching them. If somebody asked you to stop, suppress the address.'}\n </Typography>\n </Stack>\n )\n}\nListMembersPanel.displayName = 'ListMembersPanel'\n\nexport default ListMembersPanel\n"],"names":["readMarketingBasis","mdiAccountRemoveOutline","MdiIcon","useConfirmationContext","ListFilterChips","ListPagination","ListQueryNotices","listQueryRefusals","ListRowActions","ListTable","listActionsColumn","listFilterGridColumns","TABLE_ROW_HEIGHT","useListGridFilter","useSnackbar","Alert","Box","Button","Checkbox","Chip","FormControlLabel","Stack","TextField","Typography","collection","deleteDoc","doc","useCallback","useEffect","useMemo","useState","useFirestore","useUser","useListQuery","authorizedFetch","LIST_MEMBER_FILTER_HEADERS","LIST_MEMBER_FILTER_OPTIONS","LIST_MEMBER_QUERY","consentLabel","member","group","record","when","basisAtMs","Date","toLocaleDateString","basis","key","label","color","assertedBy","otherGrant","VIA_LABELS","rule","manual","MEMBER_SELECT_FIELDS","viaOf","via","splitAddresses","value","split","map","entry","trim","replace","filter","Boolean","ListMembersPanel","props","hostId","consentGroup","scope","listId","listName","onMembershipChanged","findRule","ruleSummary","firestore","data","user","enqueueSnackbar","confirm","gridFilter","selectFields","rows","members","hasMore","page","setPage","pageSize","setPageSize","plan","declaration","request","clauses","search","searchWords","deps","idField","filtering","length","some","word","onFilterChange","filterKey","JSON","stringify","filters","parse","refusals","refused","fields","headers","options","addInput","setAddInput","addName","setAddName","preview","setPreview","attested","setAttested","results","setResults","busy","setBusy","found","setFound","addresses","emails","post","route","body","response","method","payload","json","catch","ok","handlePreview","error","variant","allowDuplicate","handleFind","Number","matched","persist","handleAdd","name","attestConsent","added","handleRemove","accepted","title","description","email","confirmationText","confirmationButtonProps","then","$id","console","enrollable","optedIn","needAttestation","columns","field","headerName","flex","minWidth","valueGetter","_value","row","width","addedAt","toDate","getTime","renderCell","direction","spacing","sx","alignItems","size","source","noWrap","consent","String","items","icon","path","destructive","onClick","py","border","borderColor","borderRadius","p","clause","disabled","severity","toLocaleString","truncated","complete","useFlexGap","flexWrap","placeholder","multiline","minRows","maxRows","onChange","event","target","helperText","flexGrow","join","verdicts","verdict","refusal","input","summary","control","checked","result","enrolled","setClauses","notices","aria-label","rowHeight","hideFooter","filterMode","filterModel","onFilterModelChange","quickFilter","disableColumnSorting","noRowsLabel","rowCount","onPageChange","onPageSizeChange","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0CC,GAED,SACEA,kBAAkB,QAGb,eAAc;AACrB,SAASC,uBAAuB,QAAQ,yBAAwB;AAChE,SAASC,OAAO,EAAEC,sBAAsB,QAAQ,uBAAsB;AACtE,OAAOC,qBAAqB,8DAA6D;AACzF,SAASC,cAAc,QAAQ,4DAA2D;AAC1F,OAAOC,oBACLC,iBAAiB,QACZ,+DAA8D;AACrE,SACEC,cAAc,EACdC,SAAS,EACTC,iBAAiB,QACZ,uDAAsD;AAC7D,SAASC,qBAAqB,QAAQ,8CAA6C;AACnF,SAASC,gBAAgB,QAAQ,8CAA6C;AAC9E,SAASC,iBAAiB,QAAQ,kDAAiD;AACnF,SAASC,WAAW,QAAQ,8BAA6B;AACzD,SACEC,KAAK,EACLC,GAAG,EACHC,MAAM,EACNC,QAAQ,EACRC,IAAI,EACJC,gBAAgB,EAChBC,KAAK,EACLC,SAAS,EACTC,UAAU,QACL,gBAAe;AAEtB,SAASC,UAAU,EAAEC,SAAS,EAAEC,GAAG,QAAQ,qBAAoB;AAC/D,SAASC,WAAW,EAAEC,SAAS,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AACjE,SAASC,YAAY,EAAEC,OAAO,QAAQ,iCAAgC;AACtE,SAASC,YAAY,QAAQ,sDAAqD;AAClF,SAASC,eAAe,QAAQ,2CAA0C;AAC1E,SACEC,0BAA0B,EAC1BC,0BAA0B,EAC1BC,iBAAiB,QACZ,+BAA2B;AA4JlC,SAASC,aACPC,MAAiB,EACjBC,KAAmB;IAEnB,MAAMC,SAASzC,mBAAmBuC,QAAmCC;IACrE,MAAME,OAAOD,OAAOE,SAAS,GACzB,CAAC,GAAG,EAAE,IAAIC,KAAKH,OAAOE,SAAS,EAAEE,kBAAkB,IAAI,GACvD;IACJ,IAAIJ,OAAOK,KAAK,KAAK,YAAY;QAC/B,OAAO;YAAEC,KAAK;YAAaC,OAAO,CAAC,SAAS,EAAEN,MAAM;YAAEO,OAAO;QAAQ;IACvE;IACA,IAAIR,OAAOK,KAAK,KAAK,WAAW;QAC9B;;;;;KAKC,GACD,OAAOL,OAAOS,UAAU,KAAK,aACzB;YAAEH,KAAK;YAAYC,OAAO,CAAC,qBAAqB,EAAEN,MAAM;YAAEO,OAAO;QAAU,IAC3E;YAAEF,KAAK;YAAYC,OAAO,CAAC,QAAQ,EAAEN,MAAM;YAAEO,OAAO;QAAU;IACpE;IACA;;;;;GAKC,GACD,IAAIR,OAAOU,UAAU,KAAK,cAAc;QACtC,OAAO;YAAEJ,KAAK;YAAcC,OAAO;YAA4BC,OAAO;QAAU;IAClF;IACA,IAAIR,OAAOU,UAAU,KAAK,YAAY;QACpC,OAAO;YAAEJ,KAAK;YAAWC,OAAO;YAA+BC,OAAO;QAAU;IAClF;IACA,OAAO;QAAEF,KAAK;QAAQC,OAAO;QAAsBC,OAAO;IAAU;AACtE;AAEA,0DAA0D,GAC1D,MAAMG,aAA0D;IAC9DC,MAAM;IACNC,QAAQ;AACV;AACA,sEAAsE,GACtE,MAAMC,uBAAuB;IAAC;CAAM;AAEpC,4EAA4E,GAC5E,MAAMC,QAAQ,CAACjB,SACbA,OAAOkB,GAAG,KAAK,SAAS,SAAS;AAEnC,6EAA6E,GAC7E,OAAO,SAASC,eAAeC,KAAa;IAC1C,OAAOA,MACJC,KAAK,CAAC,WACNC,GAAG,CAAC,CAACC,QAAUA,MAAMC,IAAI,GAC1B,uEAAuE;KACtEF,GAAG,CAAC,CAACC,QAAUA,MAAME,OAAO,CAAC,cAAc,KAC3CC,MAAM,CAACC;AACZ;AAEA,OAAO,SAASC,iBAAiBC,KAA4B;IAC3D,MAAM,EACJC,MAAM,EACNC,YAAY,EACZC,KAAK,EACLC,MAAM,EACNC,QAAQ,EACRC,mBAAmB,EACnBC,QAAQ,EACRC,WAAW,EACZ,GAAGR;IACJ,MAAMS,YAAY9C;IAClB,MAAM,EAAE+C,MAAMC,IAAI,EAAE,GAAG/C;IACvB,MAAM,EAAEgD,eAAe,EAAE,GAAGlE;IAC5B,MAAM,EAAEmE,OAAO,EAAE,GAAG9E;IAEpB;;;;;;;;;;;;;;;GAeC,GACD,MAAM+E,aAAarE,kBAAkB;QAAEsE,cAAc5B;IAAqB;IAC1E,MAAM,EACJ6B,MAAMC,OAAO,EACbC,OAAO,EACPC,IAAI,EACJC,OAAO,EACPC,QAAQ,EACRC,WAAW,EACXC,IAAI,EACL,GAAG1D,aAAwB;QAC1BT,YAAYA,WAAWqD,WAAWN,KAAK,CAAC,EAAE,EAAEA,KAAK,CAAC,EAAE,EAAE,SAASC,QAAQ;QACvEoB,aAAavD;QACbwD,SAAS;YAAEC,SAASZ,WAAWY,OAAO;YAAEC,QAAQb,WAAWc,WAAW;QAAC;QACvEC,MAAM;YAACpB;YAAWN,KAAK,CAAC,EAAE;YAAEA,KAAK,CAAC,EAAE;YAAEC;SAAO;QAC7C0B,SAAS;IACX;IACA,MAAMC,YACJjB,WAAWY,OAAO,CAACM,MAAM,GAAG,KAAKlB,WAAWc,WAAW,CAACK,IAAI,CAAC,CAACC,OAASA,KAAKvC,IAAI;IAClF,MAAM,EAAEwC,cAAc,EAAE,GAAGnC;IAC3B,MAAMoC,YAAYL,YAAYM,KAAKC,SAAS,CAACf,KAAKgB,OAAO,IAAI;IAC7D/E,UAAU;QACR,IAAI,CAAC2E,gBAAgB;QACrBA,eACEC,YACI;YACExD,OAAO;YACPW,OAAO;gBAAEgD,SAASF,KAAKG,KAAK,CAACJ;YAAW;QAC1C,IACA;IAER,GAAG;QAACA;QAAWD;KAAe;IAC9B,MAAMM,WAAWhF,QACf,IACEtB,kBAAkBoF,KAAKmB,OAAO,EAAE;YAC9BC,QAAQ1E,kBAAkB0E,MAAM;YAChCC,SAAS7E;YACT8E,SAAS7E;QACX,IACF;QAACuD,KAAKmB,OAAO;KAAC;IAGhB,MAAM,CAACI,UAAUC,YAAY,GAAGrF,SAAS;IACzC,MAAM,CAACsF,SAASC,WAAW,GAAGvF,SAAS;IACvC,MAAM,CAACwF,SAASC,WAAW,GAAGzF,SAAyB;IACvD,MAAM,CAAC0F,UAAUC,YAAY,GAAG3F,SAAS;IACzC,MAAM,CAAC4F,SAASC,WAAW,GAAG7F,SAA6B;IAC3D,MAAM,CAAC8F,MAAMC,QAAQ,GAAG/F,SAAS;IACjC;;;;;;;;GAQC,GACD,MAAM,CAACgG,OAAOC,SAAS,GAAGjG,SAA6B;IAEvD,MAAMkG,YAAYF,QAAQA,MAAMG,MAAM,GAAGvE,eAAewD;IAExD;;;;;;;;;;GAUC,GACDtF,UAAU;QACR2F,WAAW;QACXE,YAAY;QACZE,WAAW;QACXI,SAAS;IACX,GAAG;QAACb;KAAS;IAEb,MAAMgB,OAAOvG,YACX,OAAOwG,OAAeC;QACpB,MAAMC,WAAW,MAAMnG,gBAAgB6C,MAAM,CAAC,WAAW,EAAEoD,OAAO,EAAE;YAClEG,QAAQ;YACRtB,SAAS;gBAAE,gBAAgB;YAAmB;YAC9CoB,MAAM3B,KAAKC,SAAS,CAAC;gBAAErC;gBAAQG;eAAW4D;QAC5C;QACA,MAAMG,UAAU,MAAMF,SAASG,IAAI,GAAGC,KAAK,CAAC,IAAO,CAAA,CAAC,CAAA;QACpD,OAAO;YAAEC,IAAIL,SAASK,EAAE;YAAEH;QAAQ;IACpC,GACA;QAACxD;QAAMV;QAAQG;KAAO;IAGxB,MAAMmE,gBAAgBhH,YAAY;QAChC,IAAIiG,QAAQ,CAACI,UAAU5B,MAAM,EAAE;QAC/ByB,QAAQ;QACR,IAAI;YACF,MAAM,EAAEa,EAAE,EAAEH,OAAO,EAAE,GAAG,MAAML,KAAK,wBAAwB;gBACzDD,QAAQD;YACV;YACA,IAAI,CAACU,IAAI;;gBACP,OAAO,KAAK1D,wBACVuD,2BAAAA,QAASK,KAAK,mBAAI,uCAClB;oBAAEC,SAAS;oBAAWC,gBAAgB;gBAAK;YAE/C;YACAvB,WAAWgB;QACb,EAAE,eAAM;YACNvD,gBAAgB,uCAAuC;gBACrD6D,SAAS;gBACTC,gBAAgB;YAClB;QACF,SAAU;YACRjB,QAAQ;QACV;IACF,GAAG;QAACD;QAAMI;QAAWE;QAAMlD;KAAgB;IAE3C,MAAM+D,aAAapH,YAAY;QAC7B,IAAIiG,QAAQ,CAACjD,UAAU;QACvBkD,QAAQ;QACR,IAAI;;YACF,MAAM,EAAEa,EAAE,EAAEH,OAAO,EAAE,GAAG,MAAML,KAAK,qBAAqB;gBACtD7E,MAAMsB;YACR;YACA,IAAI,CAAC+D,IAAI;;gBACP,OAAO,KAAK1D,yBACVuD,2BAAAA,QAASK,KAAK,oBAAI,yCAClB;oBAAEC,SAAS;oBAAWC,gBAAgB;gBAAK;YAE/C;YACA;;;;;;OAMC,GACDf,SAASQ;YACThB,WAAWgB;YACXd,YAAY;YACZE,WAAW;YACX,IAAI,CAACqB,eAAOT,2BAAAA,QAASU,OAAO,mBAAI,IAAI;gBAClCjE,gBAAgB,8BAA8B;oBAC5C6D,SAAS;oBACTK,SAAS;gBACX;YACF;QACF,EAAE,eAAM;YACNlE,gBAAgB,yCAAyC;gBACvD6D,SAAS;gBACTC,gBAAgB;YAClB;QACF,SAAU;YACRjB,QAAQ;QACV;IACF,GAAG;QAACD;QAAMjD;QAAUuD;QAAMlD;KAAgB;IAE1C,MAAMmE,YAAYxH,YAAY;QAC5B,IAAIiG,QAAQ,CAACN,SAAS;QACtBO,QAAQ;QACR,IAAI;;YACF,MAAM,EAAEa,EAAE,EAAEH,OAAO,EAAE,GAAG,MAAML,KAAK,oBAAoB;gBACrDD,QAAQD;eACJA,UAAU5B,MAAM,KAAK,KAAKgB,QAAQrD,IAAI,KACtC;gBAAEqF,MAAMhC,QAAQrD,IAAI;YAAG,IACvB,CAAC;gBACL,+DAA+D;gBAC/D,sEAAsE;gBACtE,2CAA2C;gBAC3CsF,eAAe7B;;YAEjB,IAAI,CAACkB,IAAI;;gBACP,OAAO,KAAK1D,yBACVuD,2BAAAA,QAASK,KAAK,oBAAI,gCAClB;oBAAEC,SAAS;oBAAWC,gBAAgB;gBAAK;YAE/C;YACA,MAAMQ,QAAQN,eAAOT,2BAAAA,QAASe,KAAK,mBAAI;YACvC3B,oBAAYY,2BAAAA,QAASb,OAAO,oBAAI,EAAE;YAClCH,WAAW;YACX,IAAI+B,OAAO;gBACTnC,YAAY;gBACZE,WAAW;gBACXU,SAAS;gBACTrD,uCAAAA;YACF;YACAM,gBACEsE,UAAU,IAAI,qBAAqB,GAAGA,MAAM,aAAa,CAAC,EAC1D;gBAAET,SAASS,QAAQ,YAAY;gBAAWJ,SAAS;YAAM;QAE7D,EAAE,eAAM;YACNlE,gBAAgB,gCAAgC;gBAC9C6D,SAAS;gBACTC,gBAAgB;YAClB;QACF,SAAU;YACRjB,QAAQ;QACV;IACF,GAAG;QACDD;QACAN;QACAU;QACAZ;QACAI;QACAU;QACAlD;QACAN;KACD;IAED,MAAM6E,eAAe5H,YACnB,OAAOY;YAIEA;QAHP,MAAMiH,WAAW,MAAMvE,QAAQ;YAC7BwE,OAAO;YACPC,aACE,IAAGnH,gBAAAA,OAAOoH,KAAK,YAAZpH,gBAAgB,cAAc,sBAAsB,EAAEkC,SAAS,EAAE,CAAC,GACrE,mEACA,qEACA,qEACA;YACFmF,kBAAkB;YAClBC,yBAAyB;gBAAE5G,OAAO;YAAQ;QAC5C,EACE,uEAAuE;QACvE,qEAAqE;SACpE6G,IAAI,CAAC,IAAM,MACXrB,KAAK,CAAC,IAAM;QACf,IAAI,CAACe,UAAU;QACf,IAAI;YACF,MAAM/H,UACJC,IACEmD,WACAN,KAAK,CAAC,EAAE,EACRA,KAAK,CAAC,EAAE,EACR,SACAC,QACA,WACAjC,OAAOwH,GAAG;YAGdrF,uCAAAA;YACAM,gBAAgB,yBAAyB;gBACvC6D,SAAS;gBACTK,SAAS;YACX;QACF,EAAE,OAAON,OAAO;YACdoB,QAAQpB,KAAK,CAACA;YACd5D,gBAAgB,yBAAyB;gBAAE6D,SAAS;YAAQ;QAC9D;IACF,GACA;QACE5D;QACAR;QACAI;QACAN;QACAC;QACAQ;QACAN;KACD;IAGH,MAAMuF,aAAa3C,UACfA,QAAQ4C,OAAO,GAAG5C,QAAQ6C,eAAe,GACzC;IAEJ,MAAMC,UAAwB;QAC5B;YACEC,OAAO;YACPC,YAAY;YACZC,MAAM;YACNC,UAAU;YACVC,aAAa,CAACC,QAAQC;oBAAQA;wBAAAA,aAAAA,IAAIhB,KAAK,YAATgB,aAAa;;QAC7C;QACA;YACEN,OAAO;YACPC,YAAY;YACZC,MAAM;YACNC,UAAU;YACVC,aAAa,CAACC,QAAQC;oBAAQA;wBAAAA,YAAAA,IAAIvB,IAAI,YAARuB,YAAY;;QAC5C;QACA;YACEN,OAAO;YACPC,YAAY;YACZM,OAAO;YACP,gDAAgD;YAChDH,aAAa,CAACC,QAAQC;;oBAAQA,6BAAAA,qBAAAA,sBAAAA;gCAAAA,eAAAA,IAAIE,OAAO,sBAAXF,uBAAAA,aAAaG,MAAM,sBAAnBH,sBAAAA,0BAAAA,mCAAAA,8BAAAA,oBAAyBI,OAAO,qBAAhCJ,iCAAAA,uCAAwC;;YACtEK,YAAY,CAAC,EAAEL,GAAG,EAAE;oBAClBA;uBAAAA,EAAAA,eAAAA,IAAIE,OAAO,qBAAXF,aAAaG,MAAM,IAAGH,IAAIE,OAAO,CAACC,MAAM,GAAGjI,kBAAkB,KAAK;;QACtE;QACA;YACEwH,OAAO;YACPC,YAAY;YACZM,OAAO;YACPH,aAAa,CAACC,QAAQC,MAAQnH,MAAMmH;YACpC;;;;;OAKC,GACDK,YAAY,CAAC,EAAEL,GAAG,EAAE,iBAClB,MAACtJ;oBAAM4J,WAAU;oBAAMC,SAAS;oBAAGC,IAAI;wBAAEC,YAAY;wBAAUZ,UAAU;oBAAE;;sCACzE,KAACrJ;4BAAKkK,MAAK;4BAAQxC,SAAQ;4BAAW7F,OAAOI,UAAU,CAACI,MAAMmH,KAAK;;wBAClEA,IAAIW,MAAM,iBACT,KAAC/J;4BAAWsH,SAAQ;4BAAU5F,OAAM;4BAAiBsI,MAAM;sCACxDZ,IAAIW,MAAM;6BAEX;;;QAGV;QACA;YACEjB,OAAO;YACPC,YAAY;YACZM,OAAO;YACPH,aAAa,CAACC,QAAQC,MAAQrI,aAAaqI,KAAKrG,cAActB,KAAK;YACnEgI,YAAY,CAAC,EAAEL,GAAG,EAAE;gBAClB,MAAMa,UAAUlJ,aAAaqI,KAAKrG;gBAClC,qBACE,KAACnD;oBACCkK,MAAK;oBACLxC,SAAQ;oBACR5F,OAAOuI,QAAQvI,KAAK;oBACpBD,OAAOwI,QAAQxI,KAAK;;YAG1B;QACF;QACA;;;;;KAKC,GACDtC,kBACE,CAACiK;gBAEiBA;iCADhB,KAACnK;gBACCwC,OAAOyI,QAAOd,aAAAA,IAAIhB,KAAK,YAATgB,aAAaA,IAAIZ,GAAG;gBAClC2B,OAAO;oBACL;wBACE3I,KAAK;wBACLC,OAAO;wBACP2I,oBAAM,KAACzL;4BAAQ0L,MAAM3L,wBAAwB2L,IAAI;4BAAEP,MAAM;;wBACzDQ,aAAa;wBACbC,SAAS,IAAM,KAAKvC,aAAaoB;oBACnC;iBACD;;WAGL;YAAEC,OAAO;QAAG;KAEf;IAED,qBACE,MAACvJ;QAAM6J,SAAS;QAAGC,IAAI;YAAEY,IAAI;QAAE;;YAQ5BpH,yBACC,MAACtD;gBACC6J,SAAS;gBACTC,IAAI;oBAAEa,QAAQ;oBAAGC,aAAa;oBAAWC,cAAc;oBAAGC,GAAG;gBAAI;;kCAEjE,KAAC5K;wBAAWsH,SAAQ;wBAAW5F,OAAM;kCAClC;;oBAEF,CAAC2B,sBAAAA,cAAe,EAAE,EAAEf,GAAG,CAAC,CAACuI,uBACxB,KAAC7K;4BAAwBsH,SAAQ;sCAC9BuD;2BADcA;kCAInB,KAACpL;kCACC,cAAA,KAACC;4BACCoK,MAAK;4BACLxC,SAAQ;4BACRwD,UAAUzE;4BACVkE,SAAS,IAAM,KAAK/C;sCAEnBnB,OAAO,eAAe;;;oBAG1BE,sBACC,KAAC/G;wBAAMuL,UAAUxE,MAAMmB,OAAO,GAAG,SAAS;kCACvC,AAACnB,CAAAA,MAAMmB,OAAO,KAAK,IAChB,qBACA,GAAGnB,MAAMmB,OAAO,CAACsD,cAAc,GAAG,aAAa,CAAC,AAAD,IAChDzE,CAAAA,MAAM0E,SAAS,GACZ,CAAC,+BAA+B,EAAE1E,MAAMG,MAAM,CAAC7B,MAAM,CAAC,EAAE,CAAC,GACzD,8CACA,EAAC,IACJ0B,CAAAA,MAAM2E,QAAQ,GACX,KACA,2DACA,sCAAqC;yBAE3C;kCACJ,KAAClL;wBAAWsH,SAAQ;wBAAU5F,OAAM;kCACjC,oEACC,gEACA;;;iBAGJ;0BAEJ,KAAC1B;gBAAWsH,SAAQ;gBAAQ5F,OAAM;0BAC/B,qEACC,oEACA;;0BAGJ,MAAC5B;gBAAM4J,WAAU;gBAAMC,SAAS;gBAAGwB,UAAU;gBAACvB,IAAI;oBAAEwB,UAAU;gBAAO;;kCACnE,KAACrL;wBACC+J,MAAK;wBACLrI,OAAM;wBACN4J,aAAY;wBACZC,SAAS;wBACTC,SAAS;wBACTC,SAAS;wBACTpJ,OAAOuD;wBACP8F,UAAU,CAACC,QAAU9F,YAAY8F,MAAMC,MAAM,CAACvJ,KAAK;wBACnDwJ,YACEnF,UAAU5B,MAAM,GAAG,IACf,GAAG4B,UAAU5B,MAAM,CAAC,UAAU,CAAC,GAC/B;wBAEN+E,IAAI;4BAAEiC,UAAU;4BAAG5C,UAAU;wBAAI;;oBAElCxC,UAAU5B,MAAM,GAAG,IAAI,qBACtB,KAAC9E;wBACC+J,MAAK;wBACLrI,OAAM;wBACNW,OAAOyD;wBACP4F,UAAU,CAACC,QAAU5F,WAAW4F,MAAMC,MAAM,CAACvJ,KAAK;wBAClDwH,IAAI;4BAAEX,UAAU;wBAAI;;kCAGxB,KAACxJ;kCACC,cAAA,KAACC;4BACCoK,MAAK;4BACLxC,SAAQ;4BACRwD,UAAUzE,QAAQ,CAACI,UAAU5B,MAAM,IAAIlC,QAAQoD;4BAC/CwE,SAAS,IAAM,KAAKnD;sCAEnBf,OAAO,cAAc;;;;;YAW3BN,wBACC,MAACjG;gBAAM6J,SAAS;;kCACd,KAACnK;wBAAMuL,UAAUrC,aAAa,SAAS;kCACpC;4BACC3C,QAAQ4C,OAAO,GACX,GAAG5C,QAAQ4C,OAAO,CAAC,iBAAiB,CAAC,GACrC;4BACJ5C,QAAQ6C,eAAe,GACnB,GAAG7C,QAAQ6C,eAAe,CAAC,yBAAyB,CAAC,GACrD;4BACJ7C,QAAQR,OAAO,GACX,GAAGQ,QAAQR,OAAO,CAAC,uBAAuB,CAAC,GAC3C;yBACL,CACE7C,MAAM,CAACC,SACPmJ,IAAI,CAAC;;oBAQT/F,QAAQgG,QAAQ,CACdrJ,MAAM,CAAC,CAACsJ,UAAYA,QAAQC,OAAO,EACnC3J,GAAG,CAAC,CAAC0J,wBACJ,KAAChM;4BAECsH,SAAQ;4BACR5F,OAAM;sCAEL,GAAGsK,QAAQE,KAAK,CAAC,GAAG,EAAEF,QAAQG,OAAO,EAAE;2BAJnCH,QAAQE,KAAK;oBAOvBnG,QAAQ6C,eAAe,iBACtB,KAAC/I;wBACCuM,uBACE,KAACzM;4BACC0M,SAASpG;4BACTwF,UAAU,CAACC,QAAUxF,YAAYwF,MAAMC,MAAM,CAACU,OAAO;;wBAGzD5K,qBACE,KAACzB;4BAAWsH,SAAQ;sCACjB,CAAC,iDAAiD,CAAC,GAClD,GAAGvB,QAAQ6C,eAAe,CAAC,CAAC,CAAC,GAC7B,GAAG7C,QAAQ6C,eAAe,KAAK,IAAI,WAAW,SAAS,CAAC,CAAC,GACzD,8DACA;;yBAIN;kCACJ,KAACnJ;kCACC,cAAA,KAACC;4BACCoK,MAAK;4BACLxC,SAAQ;4BACRwD,UACEzE,QACA,CAACqC,cACA3C,QAAQ6C,eAAe,GAAG,KAAK,CAAC3C;4BAEnCsE,SAAS,IAAM,KAAK3C;sCAEnBvB,OAAO,YAAY,CAAC,IAAI,EAAEqC,YAAY;;;;iBAI3C;YAOHvC,CAAAA,2BAAAA,QAAStB,MAAM,kBACd,KAAC/E;gBAAM6J,SAAS;0BACbxD,QACEzD,MAAM,CAAC,CAAC4J,SAAW,CAACA,OAAOC,QAAQ,EACnCjK,GAAG,CAAC,CAACgK;wBAM+BA;yCALnC,KAACtM;wBAECsH,SAAQ;wBACR5F,OAAM;kCAEL,GAAG4K,OAAOJ,KAAK,CAAC,cAAc,GAAEI,gBAAAA,OAAOjF,KAAK,YAAZiF,gBAAgB,IAAI;uBAJhDA,OAAOJ,KAAK;;iBAQvB;YAEHpI,QAAQe,MAAM,KAAK,KAAK,CAACd,WAAW,CAACa,0BACpC,KAAC5E;gBAAWsH,SAAQ;gBAAQ5F,OAAM;0BAC/B;+BAGH;;kCACE,KAAC7C;wBACC2G,QAAQ1E,kBAAkB0E,MAAM;wBAChCC,SAAS7E;wBACT2D,SAASZ,WAAWY,OAAO;wBAC3BkH,UAAU9H,WAAW6I,UAAU;wBAC/B9G,SAAS7E;;kCAEX,KAAC9B;wBAAiBwG,SAASD;wBAAUmH,SAASrI,KAAKqI,OAAO;;kCAC1D,KAACvN;wBACCwN,cAAY,CAAC,WAAW,EAAExJ,UAAU;wBACpCW,MAAMC;wBACN+E,SAASzJ,sBACPyJ,SACA/H,kBAAkB0E,MAAM,EACxB3E,4BACAD;wBAEF+L,WAAWtN;wBACX,8DAA8D;wBAC9DuN,UAAU;wBACV,6DAA6D;wBAC7D,uCAAuC;wBACvCC,YAAW;wBACXC,aAAanJ,WAAWmJ,WAAW;wBACnCC,qBAAqBpJ,WAAWoJ,mBAAmB;wBACnDC,WAAW;wBACXC,oBAAoB;wBACpBC,aAAY;;kCAEd,KAACpO;wBACCkF,MAAMA;wBACNE,UAAUA;wBACViJ,UAAUrJ,QAAQe,MAAM;wBACxBd,SAASA;wBACTqJ,cAAcnJ;wBACdoJ,kBAAkBlJ;;;;0BAexB,KAACnE;gBAAWsH,SAAQ;gBAAU5F,OAAM;0BACjC,mEACC,wEACA,kEACA,sEACA;;0BAGJ,KAAC1B;gBAAWsH,SAAQ;gBAAU5F,OAAM;0BACjC,yEACC,wEACA;;;;AAIV;AACAkB,iBAAiB0K,WAAW,GAAG;AAE/B,eAAe1K,iBAAgB"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/list-members-panel.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 * Who is on one list, and the three things you can do about it.\n *\n * The audience card could create a list and delete a list. It could not show\n * you a single name on one. This panel is the rest of it: read the membership,\n * add somebody, take somebody off. The list's own settings — its name, and the\n * rule behind a dynamic one — belong to the edit route, because they are\n * facts about the LIST rather than about who is on it.\n *\n * ## It mounts only on a list's own page\n *\n * The audience detail route renders this for the ONE list being read. A\n * membership listener per row would open one per list on the audiences table,\n * on a surface where most visits are to read the list of lists — the\n * read-on-mount shape `emails-console-read-cost.spec.tsx` meters. Nothing here\n * reads anything until a list is opened, and the add form reads nothing until\n * an address is typed.\n *\n * ## Whether somebody may be ADDED is the server's answer, always\n *\n * Every sentence about an address being typed in is `email/list-members-preview`\n * answering from that person's actual record and both suppression lists. This\n * component decides none of it. A screen that decided for itself would be a\n * second copy of the rule on the one surface whose job is to tell the operator\n * the truth about what is about to happen — including, and especially, that\n * the product knows nothing about this person's permission.\n *\n * ## The membership table reads consent through the SEND-TIME reader\n *\n * The `Consent` column answers the same question `performCampaignSend` asks of\n * the same document, so it asks it with the same function —\n * {@link readMarketingBasis}, against the consent group this console is being\n * viewed as. A column that picked the fields out of the row itself is a second\n * reader of a stored shape, and the two only agree until the shape moves.\n *\n * ## Removing is not suppressing, and the screen says so\n *\n * Taking somebody off a list is the operator changing their mind about an\n * audience. It is not the person asking to stop being mailed, it does not stop\n * another list reaching them, and it writes nothing to the suppression list.\n * Those are different acts with different records, and a console that let them\n * blur is a console where \"remove\" is quietly relied on as an unsubscribe.\n */\n\nimport {\n readMarketingBasis,\n type ConsentGroup,\n type DynamicListRule,\n} from '@aglyn/aglyn'\nimport { mdiAccountRemoveOutline } from '@aglyn/shared-data-mdi'\nimport { MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport ListFilterChips from '@aglyn/shared-ui-jsx/components/list-filter-chips.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport ListQueryNotices, {\n listQueryRefusals,\n} from '@aglyn/shared-ui-jsx/components/list-query-notices.component'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { listFilterGridColumns } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useListGridFilter } from '@aglyn/shared-ui-jsx/hooks/use-list-grid-filter'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Alert,\n Box,\n Button,\n Checkbox,\n Chip,\n FormControlLabel,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport { collection, deleteDoc, doc } from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useFirestore, useUser } from '@aglyn/tenant-feature-instance'\nimport { useListQuery } from '@aglyn/tenant-feature-instance/hooks/use-list-query'\nimport { useListColumnSort } from '@aglyn/shared-ui-jsx/hooks/use-list-column-sort'\nimport type { ListQuerySort } from '@aglyn/shared-ui-jsx/const/list-query-plan'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport {\n LIST_MEMBER_FILTER_HEADERS,\n LIST_MEMBER_FILTER_OPTIONS,\n LIST_MEMBER_QUERY,\n} from '../constants/list-queries'\n\nexport interface ListMembersPanelProps {\n hostId: string\n /**\n * The controller this membership is being read AS — the declared group of\n * sites that are one sender, or this site alone.\n *\n * Required and undefaulted, for the reason {@link readMarketingBasis} makes\n * it required: a basis runs to a brand, so a surface that has not said which\n * brand is asking cannot report one. Defaulting to the group of one here\n * would compile and would then answer a declared group's questions with a\n * single site's facts — reporting a sibling's refusal as no record at all,\n * and a pooled grant as another brand's.\n */\n consentGroup: ConsentGroup\n /** `['orgs', orgId]` — the resolved org scope the card already holds. */\n scope: readonly [string, string]\n listId: string\n listName: string\n /**\n * Called after a membership is added or removed.\n *\n * The audience page's subscriber total is a server aggregate, taken once.\n * Left alone it would go on reporting the figure from before the add — a\n * stale total directly above a live table that disagrees with it, which\n * reads as the add having failed. The page re-takes the aggregate on this\n * signal and at no other time, so nothing is re-counted while somebody is\n * only reading.\n */\n onMembershipChanged?: () => void\n /**\n * The list's saved filters, when it has any and its membership is FIXED.\n *\n * Present, the panel offers to find the people they select and add them —\n * the same act as typing addresses, with the search doing the typing. Absent\n * (a live list, or one with no filters saved), the add form is the address\n * box alone: a live list's membership is the sweep's to decide, and offering\n * to add its own matches by hand would produce hand-added copies of rows the\n * sweep already owns.\n */\n findRule?: DynamicListRule | null\n /** The same filters in sentences, so the button says what it will find. */\n ruleSummary?: readonly string[]\n /**\n * Told what the table is filtered to — `null` when it is not — so the\n * card's Export can offer \"the current filter\": the predicates this\n * table's own query is asking, which the export reads with the same query.\n */\n onFilterChange?: (filter: ListMembersFilter | null) => void\n}\n\n/** The member table's filter, as the export dialog offers it. */\nexport interface ListMembersFilter {\n label: string\n value: { filters: ReadonlyArray<{ path: string; op: string; value: unknown }> }\n}\n\n/**\n * One member row, as stored.\n *\n * The consent fields are deliberately NOT enumerated here. They live one level\n * down, in an entry per site under `marketingConsentByHost`, and a row type\n * that named them at the top would be an invitation to read them there — which\n * is exactly what this table did while `enrollListMember` wrote them nested,\n * so every membership on every list reported \"No basis on record\" while the\n * send path mailed the same people on a basis it could see perfectly well. The\n * index signature is what {@link readMarketingBasis} takes; it is the only\n * reader of those fields on this surface.\n */\ninterface MemberRow {\n $id: string\n email?: string\n name?: string\n source?: string\n via?: 'manual' | 'rule'\n addedAt?: { toDate?: () => Date }\n [field: string]: unknown\n}\n\n/** What `email/list-members-preview` answers for one address. */\ninterface AddressVerdict {\n input: string\n email: string | null\n refusal: string | null\n requiresAttestation: boolean\n summary: string\n}\n\ninterface Preview {\n verdicts: AddressVerdict[]\n optedIn: number\n needAttestation: number\n refused: number\n}\n\n/** What a filter search found, as `email/list-rule-preview` reports it. */\ninterface FoundPeople extends Preview {\n /** People the filters matched — NOT the length of `emails`. */\n matched: number\n /** `emails` is one batch of a larger match. */\n truncated: boolean\n /** False when the scan hit its budget, so `matched` is itself a floor. */\n complete: boolean\n emails: string[]\n}\n\n/** What one address's outcome was, after the add. */\ninterface AddResult {\n input: string\n email: string | null\n enrolled: boolean\n error?: string\n}\n\n/**\n * How the basis reads on screen, for the group this console is viewed as.\n *\n * ## Read through `readMarketingBasis`, never off the row\n *\n * This picked `marketingConsent`, `marketingConsentBasis` and\n * `marketingConsentAtMs` off the top of the membership document, which is\n * where `enrollListMember` used to put them. They now live in an entry per\n * site under `marketingConsentByHost`, because a basis runs to the brand it\n * was given to rather than to the account that holds the row. Nothing here\n * moved with them, so every top-level read answered `undefined` and every\n * member of every list — including people carrying a perfectly good attested\n * basis — was reported as having no basis on record, on the one screen an\n * operator consults before mailing them. `performCampaignSend` reads the same\n * documents through the function below and mailed them, correctly, at the same\n * time.\n *\n * The fix is not a second field name. It is that the send path's reader is the\n * only reader: a surface that parses the stored shape itself agrees with the\n * sender only until the shape moves, and it moves without anybody thinking to\n * look here.\n *\n * ## Five states, because the model carries five\n *\n * An attestation and an opt-in are not the same fact — one is a person's own\n * decision, the other a claim an account made on their behalf — and a table\n * rendering both as a tick is the conflation `list-members.ts` stores the\n * basis to prevent. The three that are not a grant are not one state either.\n * Absence is not refusal, and neither of them is \"opted in, to somebody else\n * in this account\": that third one is a person the sender deliberately leaves\n * alone, and an operator who cannot see why reads it as the product having\n * lost their subscriber.\n */\ntype ConsentKey =\n | 'opted-out'\n | 'attested'\n | 'opted-in'\n | 'other-site'\n | 'no-site'\n | 'none'\n\nfunction consentLabel(\n member: MemberRow,\n group: ConsentGroup,\n): { key: ConsentKey; label: string; color: 'default' | 'warning' | 'error' } {\n const record = readMarketingBasis(member as Record<string, unknown>, group)\n const when = record.basisAtMs\n ? ` · ${new Date(record.basisAtMs).toLocaleDateString()}`\n : ''\n if (record.basis === 'declined') {\n return { key: 'opted-out', label: `Opted out${when}`, color: 'error' }\n }\n if (record.basis === 'granted') {\n /*\n * `assertedBy` rather than the stored basis field, because there are two\n * ways a record says an operator asserted it — a membership's\n * `marketingConsentBasis`, and the provenance object a backfill writes —\n * and the reader has already resolved both into this one answer.\n */\n return record.assertedBy === 'operator'\n ? { key: 'attested', label: `Attested by your team${when}`, color: 'warning' }\n : { key: 'opted-in', label: `Opted in${when}`, color: 'default' }\n }\n /*\n * A grant this site may not use. Reported rather than folded into \"no\n * basis\" because the two are different situations with different answers:\n * nobody has ever asked this person, versus somebody asked them on behalf\n * of another brand in this account and this brand may not borrow it.\n */\n if (record.otherGrant === 'other-host') {\n return { key: 'other-site', label: 'Opted in to another site', color: 'warning' }\n }\n if (record.otherGrant === 'unscoped') {\n return { key: 'no-site', label: 'Opted in — no site recorded', color: 'warning' }\n }\n return { key: 'none', label: 'No basis on record', color: 'default' }\n}\n\n/** How a member got here, by the words the column draws. */\nconst VIA_LABELS: Readonly<Record<'rule' | 'manual', string>> = {\n rule: 'Rule',\n manual: 'Added',\n}\n/** The How filter's choices are picked, so the panel shows a select. */\nconst MEMBER_SELECT_FIELDS = ['via']\n/** The page-sorted header, for its notice (AGL-3680). */\nconst MEMBER_SORT_HEADERS = { consent: 'Consent' }\n\n/** How a row reads `via`: a row that predates the field was added by hand. */\nconst viaOf = (member: MemberRow): 'rule' | 'manual' =>\n member.via === 'rule' ? 'rule' : 'manual'\n\n/** Free text — a paste, a typed address, commas or newlines — to addresses. */\nexport function splitAddresses(value: string): string[] {\n return value\n .split(/[\\s,;]+/)\n .map((entry) => entry.trim())\n // A pasted mail-client line is `Name <a@b.co>`; keep the address part.\n .map((entry) => entry.replace(/^.*<|>.*$/g, ''))\n .filter(Boolean)\n}\n\nexport function ListMembersPanel(props: ListMembersPanelProps) {\n const {\n hostId,\n consentGroup,\n scope,\n listId,\n listName,\n onMembershipChanged,\n findRule,\n ruleSummary,\n } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n\n /*\n * Ordered by DOCUMENT ID and paged, not capped and re-sorted here.\n *\n * `__name__` rather than `addedAt`, which is the column a reader would\n * assume: `orderBy` FILTERS as well as sorts, so a document missing the\n * field is not in the result at all — and `addedAt` is stamped only when a\n * membership is CREATED, so every row written under the two legacy ids\n * `list-members.ts` still adopts predates it. Ordering on it would drop the\n * oldest members from their own list, silently, which is the failure that\n * turned an audience into a random sample (AGL-2501). The id is the one key\n * every row has, so it stays the default. The Joined header asks for\n * `addedAt` only since the writer stamps it on every row it touches and\n * `backfill-list-member-sort-fields.mjs` on the rest (AGL-3680).\n *\n * Every filter and search word is a predicate on the same query\n * (AGL-3321) — see `LIST_MEMBER_QUERY` — so a page is a page of the\n * matches.\n */\n const gridFilter = useListGridFilter({ selectFields: MEMBER_SELECT_FIELDS })\n /*\n * EVERY HEADER SORTS (AGL-3680). Address, Name, Joined and How order the\n * QUERY — `enrollListMember` stamps each on every row, null where there is\n * none, so no order drops a member — each `alone`: under a filter or the\n * search the list keeps its id order and says so. Consent is read per\n * viewing consent group, so it sorts the page on screen.\n */\n const [askedSort, setAskedSort] = useState<ListQuerySort | null>(null)\n const {\n rows: members,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n plan,\n } = useListQuery<MemberRow>({\n collection: collection(firestore, scope[0], scope[1], 'lists', listId, 'members'),\n declaration: LIST_MEMBER_QUERY,\n request: {\n clauses: gridFilter.clauses,\n search: gridFilter.searchWords,\n sort: askedSort,\n },\n deps: [firestore, scope[0], scope[1], listId],\n idField: '$id',\n })\n const pageSorts = useMemo(\n () => ({ consent: (row: MemberRow) => consentLabel(row, consentGroup).label }),\n [consentGroup],\n )\n const columnSort = useListColumnSort<MemberRow>({\n sorts: LIST_MEMBER_QUERY.sorts,\n sort: askedSort,\n onSortChange: setAskedSort,\n orderBy: plan.orderBy,\n rows: members,\n pageSorts,\n headers: MEMBER_SORT_HEADERS,\n })\n const filtering =\n gridFilter.clauses.length > 0 || gridFilter.searchWords.some((word) => word.trim())\n const { onFilterChange } = props\n const filterKey = filtering ? JSON.stringify(plan.filters) : ''\n useEffect(() => {\n if (!onFilterChange) return\n onFilterChange(\n filterKey\n ? {\n label: 'Members matching the table’s filters',\n value: { filters: JSON.parse(filterKey) },\n }\n : null,\n )\n }, [filterKey, onFilterChange])\n const refusals = useMemo(\n () =>\n listQueryRefusals(plan.refused, {\n fields: LIST_MEMBER_QUERY.fields,\n headers: LIST_MEMBER_FILTER_HEADERS,\n options: LIST_MEMBER_FILTER_OPTIONS,\n }),\n [plan.refused],\n )\n\n const [addInput, setAddInput] = useState('')\n const [addName, setAddName] = useState('')\n const [preview, setPreview] = useState<Preview | null>(null)\n const [attested, setAttested] = useState(false)\n const [results, setResults] = useState<AddResult[] | null>(null)\n const [busy, setBusy] = useState(false)\n /*\n * The addresses a SEARCH found, when the operator ran one.\n *\n * A second way to fill the same candidate set, not a second way to add. Once\n * this holds addresses, everything below it — the consent readout, the\n * attestation, the Add button — is the code the typed path already used, so\n * there is exactly one place in this component where somebody is put on a\n * list and exactly one gate in front of it.\n */\n const [found, setFound] = useState<FoundPeople | null>(null)\n\n const addresses = found ? found.emails : splitAddresses(addInput)\n\n /*\n * Any edit to the box invalidates the answer.\n *\n * A preview belongs to the exact addresses it was run for. Leaving a stale\n * one on screen would show a count, and an attestation checkbox, for a set\n * the operator has since changed — so the number they stand behind would\n * not be the number that acts. Typing also abandons a search: the two are\n * alternative ways of naming a candidate set, and holding both at once\n * would leave the Add button acting on whichever the code happened to\n * prefer.\n */\n useEffect(() => {\n setPreview(null)\n setAttested(false)\n setResults(null)\n setFound(null)\n }, [addInput])\n\n const post = useCallback(\n async (route: string, body: Record<string, unknown>) => {\n const response = await authorizedFetch(user, `/api/email/${route}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, listId, ...body }),\n })\n const payload = await response.json().catch(() => ({}))\n return { ok: response.ok, payload }\n },\n [user, hostId, listId],\n )\n\n const handlePreview = useCallback(async () => {\n if (busy || !addresses.length) return\n setBusy(true)\n try {\n const { ok, payload } = await post('list-members-preview', {\n emails: addresses,\n })\n if (!ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The addresses could not be checked.',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n setPreview(payload as Preview)\n } catch {\n enqueueSnackbar('The addresses could not be checked.', {\n variant: 'warning',\n allowDuplicate: true,\n })\n } finally {\n setBusy(false)\n }\n }, [busy, addresses, post, enqueueSnackbar])\n\n const handleFind = useCallback(async () => {\n if (busy || !findRule) return\n setBusy(true)\n try {\n const { ok, payload } = await post('list-rule-preview', {\n rule: findRule,\n })\n if (!ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'The audience could not be worked out.',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n /*\n * The verdicts come back from the SAME resolution the typed path runs,\n * so what lands in `preview` here is the same object the consent\n * readout below already knows how to draw. A search that returned its\n * own shaped summary would be a second consent readout, and the second\n * one is the one nobody reviews.\n */\n setFound(payload as FoundPeople)\n setPreview(payload as Preview)\n setAttested(false)\n setResults(null)\n if (!Number(payload?.matched ?? 0)) {\n enqueueSnackbar('These filters match nobody', {\n variant: 'info',\n persist: false,\n })\n }\n } catch {\n enqueueSnackbar('The audience could not be worked out.', {\n variant: 'warning',\n allowDuplicate: true,\n })\n } finally {\n setBusy(false)\n }\n }, [busy, findRule, post, enqueueSnackbar])\n\n const handleAdd = useCallback(async () => {\n if (busy || !preview) return\n setBusy(true)\n try {\n const { ok, payload } = await post('list-members-add', {\n emails: addresses,\n ...(addresses.length === 1 && addName.trim()\n ? { name: addName.trim() }\n : {}),\n // The operator's assertion, not a basis they named: the server\n // derives a pass-through from each person's own record, and this flag\n // can only ever add the attributable kind.\n attestConsent: attested,\n })\n if (!ok) {\n return void enqueueSnackbar(\n payload?.error ?? 'Nobody was added to the list',\n { variant: 'warning', allowDuplicate: true },\n )\n }\n const added = Number(payload?.added ?? 0)\n setResults((payload?.results ?? []) as AddResult[])\n setPreview(null)\n if (added) {\n setAddInput('')\n setAddName('')\n setFound(null)\n onMembershipChanged?.()\n }\n enqueueSnackbar(\n added === 1 ? 'One person added' : `${added} people added`,\n { variant: added ? 'success' : 'warning', persist: false },\n )\n } catch {\n enqueueSnackbar('Nobody was added to the list', {\n variant: 'warning',\n allowDuplicate: true,\n })\n } finally {\n setBusy(false)\n }\n }, [\n busy,\n preview,\n addresses,\n addName,\n attested,\n post,\n enqueueSnackbar,\n onMembershipChanged,\n ])\n\n const handleRemove = useCallback(\n async (member: MemberRow) => {\n const accepted = await confirm({\n title: 'Remove from this list?',\n description:\n `${member.email ?? 'This person'} stops being part of \"${listName}\" ` +\n 'and campaigns to this list stop reaching them. This is NOT an ' +\n 'unsubscribe: it does not add them to your suppression list, and ' +\n 'any other list they are on still reaches them. If they asked to ' +\n 'stop hearing from you, suppress the address instead.',\n confirmationText: 'Remove',\n confirmationButtonProps: { color: 'error' },\n })\n // `confirm` resolves with no value and REJECTS on cancel, so gating on\n // the resolved value alone would make this always proceed (AGL-950).\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n try {\n await deleteDoc(\n doc(\n firestore,\n scope[0],\n scope[1],\n 'lists',\n listId,\n 'members',\n member.$id,\n ),\n )\n onMembershipChanged?.()\n enqueueSnackbar('Removed from the list', {\n variant: 'success',\n persist: false,\n })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('They were not removed', { variant: 'error' })\n }\n },\n [\n confirm,\n listName,\n firestore,\n scope,\n listId,\n enqueueSnackbar,\n onMembershipChanged,\n ],\n )\n\n const enrollable = preview\n ? preview.optedIn + preview.needAttestation\n : 0\n\n const columns: GridColDef[] = [\n {\n field: 'email',\n headerName: 'Address',\n flex: 1,\n minWidth: 220,\n valueGetter: (_value, row) => row.email ?? '—',\n },\n {\n field: 'name',\n headerName: 'Name',\n flex: 1,\n minWidth: 160,\n valueGetter: (_value, row) => row.name ?? '—',\n },\n {\n field: 'addedAt',\n headerName: 'Joined',\n width: 120,\n // Sorted on the instant, drawn as a local date.\n valueGetter: (_value, row) => row.addedAt?.toDate?.()?.getTime?.() ?? 0,\n renderCell: ({ row }) =>\n row.addedAt?.toDate ? row.addedAt.toDate().toLocaleDateString() : '—',\n },\n {\n field: 'via',\n headerName: 'How',\n width: 200,\n valueGetter: (_value, row) => viaOf(row),\n /*\n `via` says whether a rule put them here or a person did, which is what\n decides whether they LEAVE on their own: the materializer reconciles its\n own rows away and never touches a manual one. `source` is the finer\n provenance underneath it.\n */\n renderCell: ({ row }) => (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center', minWidth: 0 }}>\n <Chip size=\"small\" variant=\"outlined\" label={VIA_LABELS[viaOf(row)]} />\n {row.source ? (\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {row.source}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n {\n field: 'consent',\n headerName: 'Consent',\n width: 190,\n valueGetter: (_value, row) => consentLabel(row, consentGroup).label,\n renderCell: ({ row }) => {\n const consent = consentLabel(row, consentGroup)\n return (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n color={consent.color}\n label={consent.label}\n />\n )\n },\n },\n /*\n A SUBSCRIBER HAS NO PAGE of their own here, so the row opens nothing —\n but taking somebody off an audience is the one act performed on one, and\n it belongs in the trailing cluster with every other list's actions rather\n than as a red text button sitting in the row.\n */\n listActionsColumn(\n (row) => (\n <ListRowActions\n label={String(row.email ?? row.$id)}\n items={[\n {\n key: 'remove',\n label: 'Remove from this list',\n icon: <MdiIcon path={mdiAccountRemoveOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleRemove(row),\n },\n ]}\n />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <Stack spacing={2} sx={{ py: 2 }}>\n {/*\n FINDING people, for a list whose membership is fixed.\n The filters are the same ones a live list is swept by; here they run\n once, on request, and what they find is offered for adding rather than\n enrolled. The count and the consent readout arrive together, because\n the second is the thing that decides how much of the first can act.\n */}\n {findRule ? (\n <Stack\n spacing={1}\n sx={{ border: 1, borderColor: 'divider', borderRadius: 1, p: 1.5 }}\n >\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Find people'}\n </Typography>\n {(ruleSummary ?? []).map((clause) => (\n <Typography key={clause} variant=\"body2\">\n {clause}\n </Typography>\n ))}\n <Box>\n <Button\n size=\"small\"\n variant=\"outlined\"\n disabled={busy}\n onClick={() => void handleFind()}\n >\n {busy ? 'Searching…' : 'Find matching people'}\n </Button>\n </Box>\n {found ? (\n <Alert severity={found.matched ? 'info' : 'warning'}>\n {(found.matched === 1\n ? '1 person matches'\n : `${found.matched.toLocaleString()} people match`) +\n (found.truncated\n ? ` — this batch covers the first ${found.emails.length}. ` +\n 'Add them, then search again for the rest.'\n : '') +\n (found.complete\n ? ''\n : ' The search stopped at its read budget, so this is at ' +\n 'least that many rather than exactly.')}\n </Alert>\n ) : null}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Whoever you add stays on this list. It does not keep growing — ' +\n 'change the membership to live on the edit page if you want ' +\n 'new matches enrolled automatically.'}\n </Typography>\n </Stack>\n ) : null}\n\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Add someone by typing their address, or paste a column of them. ' +\n 'Every address is checked against their consent record and both ' +\n 'suppression lists before anyone is added.'}\n </Typography>\n\n <Stack direction=\"row\" spacing={1} useFlexGap sx={{ flexWrap: 'wrap' }}>\n <TextField\n size=\"small\"\n label=\"Email addresses\"\n placeholder=\"someone@example.com\"\n multiline\n minRows={1}\n maxRows={6}\n value={addInput}\n onChange={(event) => setAddInput(event.target.value)}\n helperText={\n addresses.length > 1\n ? `${addresses.length} addresses`\n : 'One address, or paste many'\n }\n sx={{ flexGrow: 1, minWidth: 260 }}\n />\n {addresses.length > 1 ? null : (\n <TextField\n size=\"small\"\n label=\"Name\"\n value={addName}\n onChange={(event) => setAddName(event.target.value)}\n sx={{ minWidth: 180 }}\n />\n )}\n <Box>\n <Button\n size=\"small\"\n variant=\"outlined\"\n disabled={busy || !addresses.length || Boolean(preview)}\n onClick={() => void handlePreview()}\n >\n {busy ? 'Checking…' : 'Check'}\n </Button>\n </Box>\n </Stack>\n\n {/*\n The count, in front of the operator, BEFORE the attestation control\n exists. One assertion covers the batch, so the number it covers has to\n be on screen when it is given — an attestation offered above an\n unexamined paste is a signature on a blank page.\n */}\n {preview ? (\n <Stack spacing={1}>\n <Alert severity={enrollable ? 'info' : 'warning'}>\n {[\n preview.optedIn\n ? `${preview.optedIn} already opted in`\n : '',\n preview.needAttestation\n ? `${preview.needAttestation} with no opt-in on record`\n : '',\n preview.refused\n ? `${preview.refused} cannot be added at all`\n : '',\n ]\n .filter(Boolean)\n .join(' · ')}\n </Alert>\n {/*\n The HARD refusals only. An address that merely needs the\n attestation is not a refusal — listing it here would put the very\n people the checkbox below is about under a heading that says they\n cannot be added.\n */}\n {preview.verdicts\n .filter((verdict) => verdict.refusal)\n .map((verdict) => (\n <Typography\n key={verdict.input}\n variant=\"caption\"\n color=\"text.secondary\"\n >\n {`${verdict.input} — ${verdict.summary}`}\n </Typography>\n ))}\n {preview.needAttestation ? (\n <FormControlLabel\n control={\n <Checkbox\n checked={attested}\n onChange={(event) => setAttested(event.target.checked)}\n />\n }\n label={\n <Typography variant=\"body2\">\n {`I have permission to send marketing email to the ` +\n `${preview.needAttestation} ` +\n `${preview.needAttestation === 1 ? 'person' : 'people'} ` +\n 'above with no opt-in on record. This is recorded against ' +\n 'my account, with the date.'}\n </Typography>\n }\n />\n ) : null}\n <Box>\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={\n busy ||\n !enrollable ||\n (preview.needAttestation > 0 && !attested)\n }\n onClick={() => void handleAdd()}\n >\n {busy ? 'Adding…' : `Add ${enrollable}`}\n </Button>\n </Box>\n </Stack>\n ) : null}\n\n {/*\n What actually happened, per address. A batch where one address was\n suppressed and forty were fine is not a failure, and a bare count would\n leave the operator to work out which of their lines is missing.\n */}\n {results?.length ? (\n <Stack spacing={0.5}>\n {results\n .filter((result) => !result.enrolled)\n .map((result) => (\n <Typography\n key={result.input}\n variant=\"caption\"\n color=\"text.secondary\"\n >\n {`${result.input} — not added. ${result.error ?? ''}`}\n </Typography>\n ))}\n </Stack>\n ) : null}\n\n {members.length === 0 && !hasMore && !filtering ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Nobody is on this list yet.'}\n </Typography>\n ) : (\n <>\n <ListFilterChips\n fields={LIST_MEMBER_QUERY.fields}\n headers={LIST_MEMBER_FILTER_HEADERS}\n clauses={gridFilter.clauses}\n onChange={gridFilter.setClauses}\n options={LIST_MEMBER_FILTER_OPTIONS}\n />\n <ListQueryNotices\n refused={refusals}\n notices={[...plan.notices, ...columnSort.notices]}\n />\n <ListTable\n aria-label={`Members of ${listName}`}\n rows={columnSort.rows}\n columnSort={columnSort}\n columns={listFilterGridColumns(\n columns,\n LIST_MEMBER_QUERY.fields,\n LIST_MEMBER_FILTER_OPTIONS,\n LIST_MEMBER_FILTER_HEADERS,\n )}\n rowHeight={TABLE_ROW_HEIGHT}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n // The panel, the search and the headers go to the query (Consent\n // sorts the page, and says so); the grid itself sorts nothing.\n filterMode=\"server\"\n filterModel={gridFilter.filterModel}\n onFilterModelChange={gridFilter.onFilterModelChange}\n quickFilter\n noRowsLabel=\"No members match these filters\"\n />\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={members.length}\n hasMore={hasMore}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n </>\n )}\n\n {/*\n What the column MEANS, and what to do about the one state an operator\n can act on. \"No basis on record\" is a fact about this site's records,\n not a refusal and not a dead end — the remedy is the add form above,\n which merges onto the membership that is already here rather than\n creating a second one. Whether a basis-less member is actually mailed\n is the org's consent policy to decide and is deliberately not claimed\n here: this surface does not hold that setting, and a caption that\n guessed it would be wrong for every org on the other mode.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'\"No basis on record\" means nobody has recorded this person\\'s ' +\n 'permission for this site — it is not a refusal. To record one, put ' +\n 'their address in the box above and state that you have their ' +\n 'permission; the statement is kept against your account, with the ' +\n 'date.'}\n </Typography>\n\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Removing someone from a list is not an unsubscribe. It does not add ' +\n 'them to your suppression list, and it does not stop any other list ' +\n 'reaching them. If somebody asked you to stop, suppress the address.'}\n </Typography>\n </Stack>\n )\n}\nListMembersPanel.displayName = 'ListMembersPanel'\n\nexport default ListMembersPanel\n"],"names":["readMarketingBasis","mdiAccountRemoveOutline","MdiIcon","useConfirmationContext","ListFilterChips","ListPagination","ListQueryNotices","listQueryRefusals","ListRowActions","ListTable","listActionsColumn","listFilterGridColumns","TABLE_ROW_HEIGHT","useListGridFilter","useSnackbar","Alert","Box","Button","Checkbox","Chip","FormControlLabel","Stack","TextField","Typography","collection","deleteDoc","doc","useCallback","useEffect","useMemo","useState","useFirestore","useUser","useListQuery","useListColumnSort","authorizedFetch","LIST_MEMBER_FILTER_HEADERS","LIST_MEMBER_FILTER_OPTIONS","LIST_MEMBER_QUERY","consentLabel","member","group","record","when","basisAtMs","Date","toLocaleDateString","basis","key","label","color","assertedBy","otherGrant","VIA_LABELS","rule","manual","MEMBER_SELECT_FIELDS","MEMBER_SORT_HEADERS","consent","viaOf","via","splitAddresses","value","split","map","entry","trim","replace","filter","Boolean","ListMembersPanel","props","hostId","consentGroup","scope","listId","listName","onMembershipChanged","findRule","ruleSummary","firestore","data","user","enqueueSnackbar","confirm","gridFilter","selectFields","askedSort","setAskedSort","rows","members","hasMore","page","setPage","pageSize","setPageSize","plan","declaration","request","clauses","search","searchWords","sort","deps","idField","pageSorts","row","columnSort","sorts","onSortChange","orderBy","headers","filtering","length","some","word","onFilterChange","filterKey","JSON","stringify","filters","parse","refusals","refused","fields","options","addInput","setAddInput","addName","setAddName","preview","setPreview","attested","setAttested","results","setResults","busy","setBusy","found","setFound","addresses","emails","post","route","body","response","method","payload","json","catch","ok","handlePreview","error","variant","allowDuplicate","handleFind","Number","matched","persist","handleAdd","name","attestConsent","added","handleRemove","accepted","title","description","email","confirmationText","confirmationButtonProps","then","$id","console","enrollable","optedIn","needAttestation","columns","field","headerName","flex","minWidth","valueGetter","_value","width","addedAt","toDate","getTime","renderCell","direction","spacing","sx","alignItems","size","source","noWrap","String","items","icon","path","destructive","onClick","py","border","borderColor","borderRadius","p","clause","disabled","severity","toLocaleString","truncated","complete","useFlexGap","flexWrap","placeholder","multiline","minRows","maxRows","onChange","event","target","helperText","flexGrow","join","verdicts","verdict","refusal","input","summary","control","checked","result","enrolled","setClauses","notices","aria-label","rowHeight","hideFooter","filterMode","filterModel","onFilterModelChange","quickFilter","noRowsLabel","rowCount","onPageChange","onPageSizeChange","displayName"],"mappings":"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"}
|
|
@@ -36,10 +36,14 @@ import { useRouter } from "next/navigation";
|
|
|
36
36
|
import { useEffect, useMemo, useState } from "react";
|
|
37
37
|
import { useFirestore, useUser } from "@aglyn/tenant-feature-instance";
|
|
38
38
|
import { useListQuery } from "@aglyn/tenant-feature-instance/hooks/use-list-query";
|
|
39
|
+
import { useListColumnSort } from "@aglyn/shared-ui-jsx/hooks/use-list-column-sort";
|
|
39
40
|
import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
|
|
40
41
|
import { EMAIL_LIST_FILTER_HEADERS, EMAIL_LIST_FILTER_OPTIONS, EMAIL_LIST_QUERY } from "../constants/list-queries.js";
|
|
41
42
|
import { useEmailDataScope } from "./email-org-mount.js";
|
|
42
|
-
/** The Membership filter's choices are picked, so the panel shows a select. */ const
|
|
43
|
+
/** The Membership filter's choices are picked, so the panel shows a select. */ /** The page-sorted header, for its notice (AGL-3680). */ const LIST_SORT_HEADERS = {
|
|
44
|
+
subscribers: 'Subscribers'
|
|
45
|
+
};
|
|
46
|
+
const LIST_SELECT_FIELDS = [
|
|
43
47
|
'kind'
|
|
44
48
|
];
|
|
45
49
|
/**
|
|
@@ -95,12 +99,18 @@ import { useEmailDataScope } from "./email-org-mount.js";
|
|
|
95
99
|
*/ const gridFilter = useListGridFilter({
|
|
96
100
|
selectFields: LIST_SELECT_FIELDS
|
|
97
101
|
});
|
|
102
|
+
/*
|
|
103
|
+
* EVERY HEADER SORTS (AGL-3680). List and Membership order the QUERY (see
|
|
104
|
+
* `EMAIL_LIST_QUERY`); Subscribers is counted per row after the page
|
|
105
|
+
* loads, so it sorts the page on screen and says so.
|
|
106
|
+
*/ const [askedSort, setAskedSort] = useState(EMAIL_LIST_QUERY.sorts[0]);
|
|
98
107
|
const { rows: lists, hasMore, page, setPage, pageSize, setPageSize, plan } = useListQuery({
|
|
99
108
|
collection: scope ? collection(firestore, scope[0], scope[1], 'lists') : null,
|
|
100
109
|
declaration: EMAIL_LIST_QUERY,
|
|
101
110
|
request: {
|
|
102
111
|
clauses: gridFilter.clauses,
|
|
103
|
-
search: gridFilter.searchWords
|
|
112
|
+
search: gridFilter.searchWords,
|
|
113
|
+
sort: askedSort
|
|
104
114
|
},
|
|
105
115
|
deps: [
|
|
106
116
|
firestore,
|
|
@@ -148,6 +158,21 @@ import { useEmailDataScope } from "./email-org-mount.js";
|
|
|
148
158
|
scope,
|
|
149
159
|
JSON.stringify(lists.map((l)=>l.$id))
|
|
150
160
|
]);
|
|
161
|
+
const pageSorts = useMemo(()=>({
|
|
162
|
+
subscribers: (row)=>counts[row.$id]
|
|
163
|
+
}), [
|
|
164
|
+
counts
|
|
165
|
+
]);
|
|
166
|
+
const columnSort = useListColumnSort({
|
|
167
|
+
sorts: EMAIL_LIST_QUERY.sorts,
|
|
168
|
+
defaultSort: EMAIL_LIST_QUERY.sorts[0],
|
|
169
|
+
sort: askedSort,
|
|
170
|
+
onSortChange: setAskedSort,
|
|
171
|
+
orderBy: plan.orderBy,
|
|
172
|
+
rows: lists,
|
|
173
|
+
pageSorts,
|
|
174
|
+
headers: LIST_SORT_HEADERS
|
|
175
|
+
});
|
|
151
176
|
/*
|
|
152
177
|
* Creating is a DRAWER, and the drawer is the shared one.
|
|
153
178
|
*
|
|
@@ -390,23 +415,26 @@ import { useEmailDataScope } from "./email-org-mount.js";
|
|
|
390
415
|
}),
|
|
391
416
|
/*#__PURE__*/ _jsx(ListQueryNotices, {
|
|
392
417
|
refused: refusals,
|
|
393
|
-
notices:
|
|
418
|
+
notices: [
|
|
419
|
+
...plan.notices,
|
|
420
|
+
...columnSort.notices
|
|
421
|
+
]
|
|
394
422
|
}),
|
|
395
423
|
/*#__PURE__*/ _jsx(ListTable, {
|
|
396
424
|
"aria-label": "Email lists",
|
|
397
|
-
rows:
|
|
425
|
+
rows: columnSort.rows,
|
|
426
|
+
columnSort: columnSort,
|
|
398
427
|
columns: listFilterGridColumns(columns, EMAIL_LIST_QUERY.fields, EMAIL_LIST_FILTER_OPTIONS, EMAIL_LIST_FILTER_HEADERS),
|
|
399
428
|
rowHeight: TABLE_ROW_HEIGHT,
|
|
400
429
|
onOpen: (_id, row)=>router.push(listHref(row)),
|
|
401
430
|
// Paged by the footer below, so the grid must not also slice.
|
|
402
431
|
hideFooter: true,
|
|
403
|
-
// The panel and the
|
|
404
|
-
//
|
|
432
|
+
// The panel, the search and the headers go to the query
|
|
433
|
+
// (Subscribers sorts the page, and says so).
|
|
405
434
|
filterMode: "server",
|
|
406
435
|
filterModel: gridFilter.filterModel,
|
|
407
436
|
onFilterModelChange: gridFilter.onFilterModelChange,
|
|
408
437
|
quickFilter: true,
|
|
409
|
-
disableColumnSorting: true,
|
|
410
438
|
noRowsLabel: "No lists match these filters"
|
|
411
439
|
}),
|
|
412
440
|
/*#__PURE__*/ _jsx(ListPagination, {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/lists-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 { createResourceUid, pluginDocsHelp } from '@aglyn/aglyn'\nimport { nameSearchFields } from '@aglyn/aglyn/app-utils/name-search'\nimport {\n mdiDeleteOutline,\n mdiEyeOutline,\n mdiPencilOutline,\n} from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport ListFilterChips from '@aglyn/shared-ui-jsx/components/list-filter-chips.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport ListQueryNotices, {\n listQueryRefusals,\n} from '@aglyn/shared-ui-jsx/components/list-query-notices.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 { listFilterGridColumns } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useListGridFilter } from '@aglyn/shared-ui-jsx/hooks/use-list-grid-filter'\nimport { CreateArtifactDrawer } from '@aglyn/shared-ui-jsx-forms'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { Timestamp } from '@aglyn/shared-util-timestamp'\nimport { Alert, Button, Chip, Stack, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n doc,\n getCountFromServer,\n setDoc,\n} from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useEffect, useMemo, useState } from 'react'\nimport { useFirestore, useUser } from '@aglyn/tenant-feature-instance'\nimport { useListQuery } from '@aglyn/tenant-feature-instance/hooks/use-list-query'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport {\n EMAIL_LIST_FILTER_HEADERS,\n EMAIL_LIST_FILTER_OPTIONS,\n EMAIL_LIST_QUERY,\n} from '../constants/list-queries'\nimport { useEmailDataScope } from './email-org-mount'\n\nexport interface OrgListsCardProps {\n /** The site, or `null` on the organization's Emails page. */\n hostId: string | null\n /** The emails hub URL, which every list route hangs beneath. */\n basePath: string\n}\n\n/** The Membership filter's choices are picked, so the panel shows a select. */\nconst LIST_SELECT_FIELDS = ['kind']\n\n/**\n * Email lists (AGL-254): static audiences at `orgs/{orgId}/lists`, shared\n * across the org's sites. Members arrive via the enrollList automation\n * step, popup email capture, or manual adds; campaigns target a list from\n * the audience picker.\n *\n * ## A list is a resource, so it has pages\n *\n * The row opens `…/audiences/{listId}`, and everything a list can be asked or\n * told lives there or on its edit page. The membership used to expand INSIDE\n * this table, which made three things impossible at once: a merchant could not\n * link anybody to one audience, the browser's back button did not walk out of\n * a list, and the rename lived under a table it was not about.\n *\n * Row click and the row's own link are two affordances rather than one. A\n * click handler cannot be middle-clicked, copied, or opened from the browser's\n * context menu, and a link alone loses the whole-row target.\n */\nexport function OrgListsCard(props: OrgListsCardProps) {\n const { hostId, basePath } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n const { data: user } = useUser()\n // The org lookup is async (AGL-1061): `scope` is null until it settles,\n // and stays null for a host with no owning org. Lists have always been\n // org-shared (AGL-254) — there is no host path to fall back to any more\n // (AGL-1050), so creating one is held rather than misdirected. On the\n // organization's page the mount names the org and nothing is looked up.\n const { scope } = useEmailDataScope(hostId)\n\n /*\n * Ordered by the server and paged, rather than capped and re-sorted here\n * (AGL-2501, AGL-2292).\n *\n * `limit(50)` carried no `orderBy`, so Firestore answered it in\n * DOCUMENT-ID order over ids from `createResourceUid()` — an arbitrary\n * fifty of the org's lists, which the `localeCompare` below then arranged\n * alphabetically. An agency past fifty lists saw a believable A-to-Z page\n * that was missing most of the alphabet, and the campaign composer's\n * audience picker reads the same collection.\n *\n * `name` is safe to order on here, which is a claim about the writers and\n * not a preference: this card is the only thing that CREATES a list\n * (`handleCreate` refuses an empty name), the automation step and the\n * newsletter enrollment both resolve an existing list and deliberately\n * never create one, and `lists` is absent from `IMPORTABLE_FIELDS`, so no\n * restore path can produce a nameless document for `orderBy` to drop.\n *\n * Every filter and search word is a predicate on the same query\n * (AGL-3321), so a page is a page of the matches — see `EMAIL_LIST_QUERY`.\n */\n const gridFilter = useListGridFilter({ selectFields: LIST_SELECT_FIELDS })\n const {\n rows: lists,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n plan,\n } = useListQuery<any>({\n collection: scope ? collection(firestore, scope[0], scope[1], 'lists') : null,\n declaration: EMAIL_LIST_QUERY,\n request: { clauses: gridFilter.clauses, search: gridFilter.searchWords },\n deps: [firestore, scope?.[0], scope?.[1]],\n idField: '$id',\n })\n const filtering =\n gridFilter.clauses.length > 0 || gridFilter.searchWords.some((word) => word.trim())\n const refusals = useMemo(\n () =>\n listQueryRefusals(plan.refused, {\n fields: EMAIL_LIST_QUERY.fields,\n headers: EMAIL_LIST_FILTER_HEADERS,\n options: EMAIL_LIST_FILTER_OPTIONS,\n }),\n [plan.refused],\n )\n const [counts, setCounts] = useState<Record<string, number>>({})\n useEffect(() => {\n // `lists` can only be non-empty when `scope` resolved, but the\n // effect must say so for itself — it reads `scope` outside the query.\n if (!scope) return undefined\n let active = true\n void Promise.all(\n lists.map(async (list: any) => {\n try {\n const snapshot = await getCountFromServer(\n collection(\n firestore,\n scope[0],\n scope[1],\n 'lists',\n list.$id,\n 'members',\n ),\n )\n return [list.$id, snapshot.data().count] as const\n } catch {\n return [list.$id, 0] as const\n }\n }),\n ).then((entries) => {\n if (active) setCounts(Object.fromEntries(entries))\n })\n return () => {\n active = false\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [firestore, scope, JSON.stringify(lists.map((l: any) => l.$id))])\n\n /*\n * Creating is a DRAWER, and the drawer is the shared one.\n *\n * The card used to carry a name box, a membership select and a Create\n * button stacked above the table. A create form wedged over a list has\n * nowhere to grow, which is how the rule behind a dynamic list came to be\n * authored through four controls out of nine — the form could not hold the\n * rest. Naming happens here; everything the audience IS happens on its edit\n * page, which has the room.\n */\n const [createOpen, setCreateOpen] = useState(false)\n const [createError, setCreateError] = useState<unknown>(null)\n const [creating, setCreating] = useState(false)\n\n const handleCreate = async (values: Record<string, any>) => {\n const name = String(values?.['displayName'] ?? '').trim()\n // Without an org there is nowhere org-shared to put the list, and the\n // host path that used to absorb it is gone (AGL-1050). The button is\n // disabled in this state; this is the same answer for a stale closure.\n if (!name || !scope || creating) return\n setCreating(true)\n setCreateError(null)\n const id = createResourceUid()\n try {\n await setDoc(doc(firestore, scope[0], scope[1], 'lists', id), {\n // The name and the search keys the table queries (AGL-3321).\n ...nameSearchFields(name),\n /*\n * Manual until somebody says otherwise.\n *\n * A list arrives holding the people you put on it, and becomes\n * rule-driven on its edit page where the rule can actually be\n * written. Offering the choice in the naming drawer would ask for a\n * decision before the thing it decides — a rule — can be seen.\n */\n kind: 'manual',\n createdAt: Timestamp.now(),\n })\n setCreateOpen(false)\n enqueueSnackbar(`List \"${name}\" created`, {\n variant: 'success',\n persist: false,\n })\n router.push(`${basePath}/audiences/${id}`)\n } catch (error) {\n console.error(error)\n setCreateError(error)\n enqueueSnackbar('An error has occurred', { variant: 'error' })\n } finally {\n setCreating(false)\n }\n }\n\n const handleDelete = async (list: any) => {\n if (!scope) return\n const accepted = await confirm({\n title: 'Delete list?',\n description: `\"${list.name}\" and its enrollments stop being targetable; automations enrolling into it start reporting errors.`,\n confirmationText: 'Delete',\n confirmationButtonProps: { color: 'error' },\n })\n // `confirm` resolves with no value and REJECTS on cancel, so gating on\n // the resolved value alone made this always return (AGL-950).\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n // The list owns a `members` subcollection of enrolled contacts, and\n // Firestore doesn't cascade — deleting the doc from here left that PII\n // behind, unreachable but still readable to every org member. Only the\n // Admin SDK can recursiveDelete, so this goes through the erase route\n // (AGL-946).\n try {\n const response = await authorizedFetch(user, '/api/resources/erase', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n scope: scope[0],\n scopeId: scope[1],\n kind: 'lists',\n id: list.$id,\n }),\n })\n const result = await response.json().catch(() => ({}))\n if (!response.ok) throw new Error(result?.error ?? 'List delete failed')\n } catch (error: any) {\n return void enqueueSnackbar(error?.message ?? 'List delete failed', {\n variant: 'error',\n })\n }\n enqueueSnackbar('List deleted', { variant: 'success', persist: false })\n }\n\n const listHref = (list: any) => `${basePath}/audiences/${list.$id}`\n\n const rowActions = (list: any): RowActionsMenuItem[] => [\n {\n key: 'details',\n label: 'Open details',\n icon: <MdiIcon path={mdiEyeOutline.path} size={0.8} />,\n href: listHref(list),\n },\n {\n key: 'edit',\n label: 'Edit list',\n icon: <MdiIcon path={mdiPencilOutline.path} size={0.8} />,\n href: `${listHref(list)}/edit`,\n },\n {\n key: 'delete',\n label: 'Delete',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleDelete(list),\n },\n ]\n\n /*\n * One row per list. The name is a link AND the row opens the list: a click\n * handler cannot be middle-clicked, copied or opened from the context menu,\n * and a link alone loses the whole-row target.\n */\n const columns: GridColDef[] = [\n {\n field: 'name',\n headerName: 'List',\n flex: 1,\n minWidth: 200,\n renderCell: ({ row }) => (\n <AppLink\n href={listHref(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 {row.name}\n </AppLink>\n ),\n },\n {\n field: 'kind',\n headerName: 'Membership',\n flex: 1,\n minWidth: 220,\n // A list that predates `kind` is a manual one.\n valueGetter: (_value, row) => (row.kind === 'dynamic' ? 'dynamic' : 'manual'),\n renderCell: ({ row }) =>\n row.kind === 'dynamic' ? (\n <Chip\n size=\"small\"\n color=\"primary\"\n variant=\"outlined\"\n /*\n Freshness, not just kind. A dynamic list that has stopped being\n swept looks exactly like one whose population has not changed,\n so the last evaluation time is the only thing that tells them\n apart — and it is the first thing to look at when a merchant\n reports that somebody is missing from an audience.\n */\n label={\n row.lastEvaluatedAt?.toDate\n ? `Rule · ${row.lastEvaluatedAt.toDate().toLocaleString()}`\n : 'Rule · not yet evaluated'\n }\n />\n ) : (\n <Chip size=\"small\" variant=\"outlined\" label=\"Manual\" />\n ),\n },\n {\n field: 'subscribers',\n headerName: 'Subscribers',\n width: 130,\n valueGetter: (_value, row) => counts[row.$id] ?? '…',\n },\n listActionsColumn(\n (row) => (\n <ListRowActions\n label={String(row.name ?? row.$id)}\n items={rowActions(row)}\n />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <CardDisplay\n header=\"Email lists\"\n help={pluginDocsHelp('emailCampaigns', { anchor: '#email-lists' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{\n action: (\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={!scope || creating}\n onClick={() => setCreateOpen(true)}\n >\n {creating ? 'Creating…' : 'Create audience'}\n </Button>\n ),\n }}\n >\n <Stack spacing={1.5}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Audiences shared across your organization. A manual list holds ' +\n 'the people you enroll into it. A dynamic list holds everyone ' +\n 'matching a rule, re-checked about every fifteen minutes. Target ' +\n 'either from the campaign composer.'}\n </Typography>\n {/*\n A list document stores a name and nothing else descriptive, so the\n shared Description box is refused: a field the writer discards on\n write is worse than a field that was never offered.\n */}\n <CreateArtifactDrawer\n open={createOpen}\n onClose={() => setCreateOpen(false)}\n title=\"Create new audience\"\n submitLabel=\"Create audience\"\n onSubmit={handleCreate}\n errorSlot={\n createError ? (\n <Alert severity=\"error\" sx={{ mt: 2, mb: 1 }}>\n {'This audience could not be created.'}\n </Alert>\n ) : null\n }\n includeDescription={false}\n />\n {lists.length === 0 && !hasMore && !filtering ? null : (\n <>\n <ListFilterChips\n fields={EMAIL_LIST_QUERY.fields}\n headers={EMAIL_LIST_FILTER_HEADERS}\n clauses={gridFilter.clauses}\n onChange={gridFilter.setClauses}\n options={EMAIL_LIST_FILTER_OPTIONS}\n />\n <ListQueryNotices refused={refusals} notices={plan.notices} />\n <ListTable\n aria-label=\"Email lists\"\n rows={lists}\n columns={listFilterGridColumns(\n columns,\n EMAIL_LIST_QUERY.fields,\n EMAIL_LIST_FILTER_OPTIONS,\n EMAIL_LIST_FILTER_HEADERS,\n )}\n rowHeight={TABLE_ROW_HEIGHT}\n onOpen={(_id, row) => router.push(listHref(row))}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n // The panel and the search go to the query; the grid neither\n // filters nor sorts the page it holds.\n filterMode=\"server\"\n filterModel={gridFilter.filterModel}\n onFilterModelChange={gridFilter.onFilterModelChange}\n quickFilter\n disableColumnSorting\n noRowsLabel=\"No lists match these filters\"\n />\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={lists.length}\n hasMore={hasMore}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n </>\n )}\n </Stack>\n </CardDisplay>\n )\n}\nOrgListsCard.displayName = 'OrgListsCard'\n\nexport default OrgListsCard\n"],"names":["createResourceUid","pluginDocsHelp","nameSearchFields","mdiDeleteOutline","mdiEyeOutline","mdiPencilOutline","AppLink","CardDisplay","MdiIcon","useConfirmationContext","ListFilterChips","ListPagination","ListQueryNotices","listQueryRefusals","ListRowActions","ListTable","listActionsColumn","listFilterGridColumns","TABLE_ROW_HEIGHT","useListGridFilter","CreateArtifactDrawer","useSnackbar","Timestamp","Alert","Button","Chip","Stack","Typography","collection","doc","getCountFromServer","setDoc","useRouter","useEffect","useMemo","useState","useFirestore","useUser","useListQuery","authorizedFetch","EMAIL_LIST_FILTER_HEADERS","EMAIL_LIST_FILTER_OPTIONS","EMAIL_LIST_QUERY","useEmailDataScope","LIST_SELECT_FIELDS","OrgListsCard","props","hostId","basePath","firestore","router","enqueueSnackbar","confirm","data","user","scope","gridFilter","selectFields","rows","lists","hasMore","page","setPage","pageSize","setPageSize","plan","declaration","request","clauses","search","searchWords","deps","idField","filtering","length","some","word","trim","refusals","refused","fields","headers","options","counts","setCounts","undefined","active","Promise","all","map","list","snapshot","$id","count","then","entries","Object","fromEntries","JSON","stringify","l","createOpen","setCreateOpen","createError","setCreateError","creating","setCreating","handleCreate","values","name","String","id","kind","createdAt","now","variant","persist","push","error","console","handleDelete","accepted","title","description","confirmationText","confirmationButtonProps","color","catch","response","method","body","scopeId","result","json","ok","Error","message","listHref","rowActions","key","label","icon","path","size","href","destructive","onClick","columns","field","headerName","flex","minWidth","renderCell","row","event","stopPropagation","valueGetter","_value","lastEvaluatedAt","toDate","toLocaleString","width","items","header","help","anchor","contentGutterX","contentGutterY","contentBordered","HeaderProps","action","disabled","spacing","open","onClose","submitLabel","onSubmit","errorSlot","severity","sx","mt","mb","includeDescription","onChange","setClauses","notices","aria-label","rowHeight","onOpen","_id","hideFooter","filterMode","filterModel","onFilterModelChange","quickFilter","disableColumnSorting","noRowsLabel","rowCount","onPageChange","onPageSizeChange","displayName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/components/lists-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 { createResourceUid, pluginDocsHelp } from '@aglyn/aglyn'\nimport { nameSearchFields } from '@aglyn/aglyn/app-utils/name-search'\nimport {\n mdiDeleteOutline,\n mdiEyeOutline,\n mdiPencilOutline,\n} from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport ListFilterChips from '@aglyn/shared-ui-jsx/components/list-filter-chips.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport ListQueryNotices, {\n listQueryRefusals,\n} from '@aglyn/shared-ui-jsx/components/list-query-notices.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 { listFilterGridColumns } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { useListGridFilter } from '@aglyn/shared-ui-jsx/hooks/use-list-grid-filter'\nimport { CreateArtifactDrawer } from '@aglyn/shared-ui-jsx-forms'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { Timestamp } from '@aglyn/shared-util-timestamp'\nimport { Alert, Button, Chip, Stack, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n doc,\n getCountFromServer,\n setDoc,\n} from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useEffect, useMemo, useState } from 'react'\nimport { useFirestore, useUser } from '@aglyn/tenant-feature-instance'\nimport { useListQuery } from '@aglyn/tenant-feature-instance/hooks/use-list-query'\nimport { useListColumnSort } from '@aglyn/shared-ui-jsx/hooks/use-list-column-sort'\nimport type { ListQuerySort } from '@aglyn/shared-ui-jsx/const/list-query-plan'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport {\n EMAIL_LIST_FILTER_HEADERS,\n EMAIL_LIST_FILTER_OPTIONS,\n EMAIL_LIST_QUERY,\n} from '../constants/list-queries'\nimport { useEmailDataScope } from './email-org-mount'\n\nexport interface OrgListsCardProps {\n /** The site, or `null` on the organization's Emails page. */\n hostId: string | null\n /** The emails hub URL, which every list route hangs beneath. */\n basePath: string\n}\n\n/** The Membership filter's choices are picked, so the panel shows a select. */\n/** The page-sorted header, for its notice (AGL-3680). */\nconst LIST_SORT_HEADERS = { subscribers: 'Subscribers' }\nconst LIST_SELECT_FIELDS = ['kind']\n\n/**\n * Email lists (AGL-254): static audiences at `orgs/{orgId}/lists`, shared\n * across the org's sites. Members arrive via the enrollList automation\n * step, popup email capture, or manual adds; campaigns target a list from\n * the audience picker.\n *\n * ## A list is a resource, so it has pages\n *\n * The row opens `…/audiences/{listId}`, and everything a list can be asked or\n * told lives there or on its edit page. The membership used to expand INSIDE\n * this table, which made three things impossible at once: a merchant could not\n * link anybody to one audience, the browser's back button did not walk out of\n * a list, and the rename lived under a table it was not about.\n *\n * Row click and the row's own link are two affordances rather than one. A\n * click handler cannot be middle-clicked, copied, or opened from the browser's\n * context menu, and a link alone loses the whole-row target.\n */\nexport function OrgListsCard(props: OrgListsCardProps) {\n const { hostId, basePath } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n const { data: user } = useUser()\n // The org lookup is async (AGL-1061): `scope` is null until it settles,\n // and stays null for a host with no owning org. Lists have always been\n // org-shared (AGL-254) — there is no host path to fall back to any more\n // (AGL-1050), so creating one is held rather than misdirected. On the\n // organization's page the mount names the org and nothing is looked up.\n const { scope } = useEmailDataScope(hostId)\n\n /*\n * Ordered by the server and paged, rather than capped and re-sorted here\n * (AGL-2501, AGL-2292).\n *\n * `limit(50)` carried no `orderBy`, so Firestore answered it in\n * DOCUMENT-ID order over ids from `createResourceUid()` — an arbitrary\n * fifty of the org's lists, which the `localeCompare` below then arranged\n * alphabetically. An agency past fifty lists saw a believable A-to-Z page\n * that was missing most of the alphabet, and the campaign composer's\n * audience picker reads the same collection.\n *\n * `name` is safe to order on here, which is a claim about the writers and\n * not a preference: this card is the only thing that CREATES a list\n * (`handleCreate` refuses an empty name), the automation step and the\n * newsletter enrollment both resolve an existing list and deliberately\n * never create one, and `lists` is absent from `IMPORTABLE_FIELDS`, so no\n * restore path can produce a nameless document for `orderBy` to drop.\n *\n * Every filter and search word is a predicate on the same query\n * (AGL-3321), so a page is a page of the matches — see `EMAIL_LIST_QUERY`.\n */\n const gridFilter = useListGridFilter({ selectFields: LIST_SELECT_FIELDS })\n /*\n * EVERY HEADER SORTS (AGL-3680). List and Membership order the QUERY (see\n * `EMAIL_LIST_QUERY`); Subscribers is counted per row after the page\n * loads, so it sorts the page on screen and says so.\n */\n const [askedSort, setAskedSort] = useState<ListQuerySort | null>(\n EMAIL_LIST_QUERY.sorts[0],\n )\n const {\n rows: lists,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n plan,\n } = useListQuery<any>({\n collection: scope ? collection(firestore, scope[0], scope[1], 'lists') : null,\n declaration: EMAIL_LIST_QUERY,\n request: {\n clauses: gridFilter.clauses,\n search: gridFilter.searchWords,\n sort: askedSort,\n },\n deps: [firestore, scope?.[0], scope?.[1]],\n idField: '$id',\n })\n const filtering =\n gridFilter.clauses.length > 0 || gridFilter.searchWords.some((word) => word.trim())\n const refusals = useMemo(\n () =>\n listQueryRefusals(plan.refused, {\n fields: EMAIL_LIST_QUERY.fields,\n headers: EMAIL_LIST_FILTER_HEADERS,\n options: EMAIL_LIST_FILTER_OPTIONS,\n }),\n [plan.refused],\n )\n const [counts, setCounts] = useState<Record<string, number>>({})\n useEffect(() => {\n // `lists` can only be non-empty when `scope` resolved, but the\n // effect must say so for itself — it reads `scope` outside the query.\n if (!scope) return undefined\n let active = true\n void Promise.all(\n lists.map(async (list: any) => {\n try {\n const snapshot = await getCountFromServer(\n collection(\n firestore,\n scope[0],\n scope[1],\n 'lists',\n list.$id,\n 'members',\n ),\n )\n return [list.$id, snapshot.data().count] as const\n } catch {\n return [list.$id, 0] as const\n }\n }),\n ).then((entries) => {\n if (active) setCounts(Object.fromEntries(entries))\n })\n return () => {\n active = false\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [firestore, scope, JSON.stringify(lists.map((l: any) => l.$id))])\n const pageSorts = useMemo(\n () => ({ subscribers: (row: any) => counts[row.$id] }),\n [counts],\n )\n const columnSort = useListColumnSort<any>({\n sorts: EMAIL_LIST_QUERY.sorts,\n defaultSort: EMAIL_LIST_QUERY.sorts[0],\n sort: askedSort,\n onSortChange: setAskedSort,\n orderBy: plan.orderBy,\n rows: lists,\n pageSorts,\n headers: LIST_SORT_HEADERS,\n })\n\n /*\n * Creating is a DRAWER, and the drawer is the shared one.\n *\n * The card used to carry a name box, a membership select and a Create\n * button stacked above the table. A create form wedged over a list has\n * nowhere to grow, which is how the rule behind a dynamic list came to be\n * authored through four controls out of nine — the form could not hold the\n * rest. Naming happens here; everything the audience IS happens on its edit\n * page, which has the room.\n */\n const [createOpen, setCreateOpen] = useState(false)\n const [createError, setCreateError] = useState<unknown>(null)\n const [creating, setCreating] = useState(false)\n\n const handleCreate = async (values: Record<string, any>) => {\n const name = String(values?.['displayName'] ?? '').trim()\n // Without an org there is nowhere org-shared to put the list, and the\n // host path that used to absorb it is gone (AGL-1050). The button is\n // disabled in this state; this is the same answer for a stale closure.\n if (!name || !scope || creating) return\n setCreating(true)\n setCreateError(null)\n const id = createResourceUid()\n try {\n await setDoc(doc(firestore, scope[0], scope[1], 'lists', id), {\n // The name and the search keys the table queries (AGL-3321).\n ...nameSearchFields(name),\n /*\n * Manual until somebody says otherwise.\n *\n * A list arrives holding the people you put on it, and becomes\n * rule-driven on its edit page where the rule can actually be\n * written. Offering the choice in the naming drawer would ask for a\n * decision before the thing it decides — a rule — can be seen.\n */\n kind: 'manual',\n createdAt: Timestamp.now(),\n })\n setCreateOpen(false)\n enqueueSnackbar(`List \"${name}\" created`, {\n variant: 'success',\n persist: false,\n })\n router.push(`${basePath}/audiences/${id}`)\n } catch (error) {\n console.error(error)\n setCreateError(error)\n enqueueSnackbar('An error has occurred', { variant: 'error' })\n } finally {\n setCreating(false)\n }\n }\n\n const handleDelete = async (list: any) => {\n if (!scope) return\n const accepted = await confirm({\n title: 'Delete list?',\n description: `\"${list.name}\" and its enrollments stop being targetable; automations enrolling into it start reporting errors.`,\n confirmationText: 'Delete',\n confirmationButtonProps: { color: 'error' },\n })\n // `confirm` resolves with no value and REJECTS on cancel, so gating on\n // the resolved value alone made this always return (AGL-950).\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n // The list owns a `members` subcollection of enrolled contacts, and\n // Firestore doesn't cascade — deleting the doc from here left that PII\n // behind, unreachable but still readable to every org member. Only the\n // Admin SDK can recursiveDelete, so this goes through the erase route\n // (AGL-946).\n try {\n const response = await authorizedFetch(user, '/api/resources/erase', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n scope: scope[0],\n scopeId: scope[1],\n kind: 'lists',\n id: list.$id,\n }),\n })\n const result = await response.json().catch(() => ({}))\n if (!response.ok) throw new Error(result?.error ?? 'List delete failed')\n } catch (error: any) {\n return void enqueueSnackbar(error?.message ?? 'List delete failed', {\n variant: 'error',\n })\n }\n enqueueSnackbar('List deleted', { variant: 'success', persist: false })\n }\n\n const listHref = (list: any) => `${basePath}/audiences/${list.$id}`\n\n const rowActions = (list: any): RowActionsMenuItem[] => [\n {\n key: 'details',\n label: 'Open details',\n icon: <MdiIcon path={mdiEyeOutline.path} size={0.8} />,\n href: listHref(list),\n },\n {\n key: 'edit',\n label: 'Edit list',\n icon: <MdiIcon path={mdiPencilOutline.path} size={0.8} />,\n href: `${listHref(list)}/edit`,\n },\n {\n key: 'delete',\n label: 'Delete',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n onClick: () => void handleDelete(list),\n },\n ]\n\n /*\n * One row per list. The name is a link AND the row opens the list: a click\n * handler cannot be middle-clicked, copied or opened from the context menu,\n * and a link alone loses the whole-row target.\n */\n const columns: GridColDef[] = [\n {\n field: 'name',\n headerName: 'List',\n flex: 1,\n minWidth: 200,\n renderCell: ({ row }) => (\n <AppLink\n href={listHref(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 {row.name}\n </AppLink>\n ),\n },\n {\n field: 'kind',\n headerName: 'Membership',\n flex: 1,\n minWidth: 220,\n // A list that predates `kind` is a manual one.\n valueGetter: (_value, row) => (row.kind === 'dynamic' ? 'dynamic' : 'manual'),\n renderCell: ({ row }) =>\n row.kind === 'dynamic' ? (\n <Chip\n size=\"small\"\n color=\"primary\"\n variant=\"outlined\"\n /*\n Freshness, not just kind. A dynamic list that has stopped being\n swept looks exactly like one whose population has not changed,\n so the last evaluation time is the only thing that tells them\n apart — and it is the first thing to look at when a merchant\n reports that somebody is missing from an audience.\n */\n label={\n row.lastEvaluatedAt?.toDate\n ? `Rule · ${row.lastEvaluatedAt.toDate().toLocaleString()}`\n : 'Rule · not yet evaluated'\n }\n />\n ) : (\n <Chip size=\"small\" variant=\"outlined\" label=\"Manual\" />\n ),\n },\n {\n field: 'subscribers',\n headerName: 'Subscribers',\n width: 130,\n valueGetter: (_value, row) => counts[row.$id] ?? '…',\n },\n listActionsColumn(\n (row) => (\n <ListRowActions\n label={String(row.name ?? row.$id)}\n items={rowActions(row)}\n />\n ),\n { width: 72 },\n ),\n ]\n\n return (\n <CardDisplay\n header=\"Email lists\"\n help={pluginDocsHelp('emailCampaigns', { anchor: '#email-lists' })}\n contentGutterX\n contentGutterY\n contentBordered=\"all\"\n HeaderProps={{\n action: (\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={!scope || creating}\n onClick={() => setCreateOpen(true)}\n >\n {creating ? 'Creating…' : 'Create audience'}\n </Button>\n ),\n }}\n >\n <Stack spacing={1.5}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Audiences shared across your organization. A manual list holds ' +\n 'the people you enroll into it. A dynamic list holds everyone ' +\n 'matching a rule, re-checked about every fifteen minutes. Target ' +\n 'either from the campaign composer.'}\n </Typography>\n {/*\n A list document stores a name and nothing else descriptive, so the\n shared Description box is refused: a field the writer discards on\n write is worse than a field that was never offered.\n */}\n <CreateArtifactDrawer\n open={createOpen}\n onClose={() => setCreateOpen(false)}\n title=\"Create new audience\"\n submitLabel=\"Create audience\"\n onSubmit={handleCreate}\n errorSlot={\n createError ? (\n <Alert severity=\"error\" sx={{ mt: 2, mb: 1 }}>\n {'This audience could not be created.'}\n </Alert>\n ) : null\n }\n includeDescription={false}\n />\n {lists.length === 0 && !hasMore && !filtering ? null : (\n <>\n <ListFilterChips\n fields={EMAIL_LIST_QUERY.fields}\n headers={EMAIL_LIST_FILTER_HEADERS}\n clauses={gridFilter.clauses}\n onChange={gridFilter.setClauses}\n options={EMAIL_LIST_FILTER_OPTIONS}\n />\n <ListQueryNotices\n refused={refusals}\n notices={[...plan.notices, ...columnSort.notices]}\n />\n <ListTable\n aria-label=\"Email lists\"\n rows={columnSort.rows}\n columnSort={columnSort}\n columns={listFilterGridColumns(\n columns,\n EMAIL_LIST_QUERY.fields,\n EMAIL_LIST_FILTER_OPTIONS,\n EMAIL_LIST_FILTER_HEADERS,\n )}\n rowHeight={TABLE_ROW_HEIGHT}\n onOpen={(_id, row) => router.push(listHref(row))}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n // The panel, the search and the headers go to the query\n // (Subscribers sorts the page, and says so).\n filterMode=\"server\"\n filterModel={gridFilter.filterModel}\n onFilterModelChange={gridFilter.onFilterModelChange}\n quickFilter\n noRowsLabel=\"No lists match these filters\"\n />\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={lists.length}\n hasMore={hasMore}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n </>\n )}\n </Stack>\n </CardDisplay>\n )\n}\nOrgListsCard.displayName = 'OrgListsCard'\n\nexport default OrgListsCard\n"],"names":["createResourceUid","pluginDocsHelp","nameSearchFields","mdiDeleteOutline","mdiEyeOutline","mdiPencilOutline","AppLink","CardDisplay","MdiIcon","useConfirmationContext","ListFilterChips","ListPagination","ListQueryNotices","listQueryRefusals","ListRowActions","ListTable","listActionsColumn","listFilterGridColumns","TABLE_ROW_HEIGHT","useListGridFilter","CreateArtifactDrawer","useSnackbar","Timestamp","Alert","Button","Chip","Stack","Typography","collection","doc","getCountFromServer","setDoc","useRouter","useEffect","useMemo","useState","useFirestore","useUser","useListQuery","useListColumnSort","authorizedFetch","EMAIL_LIST_FILTER_HEADERS","EMAIL_LIST_FILTER_OPTIONS","EMAIL_LIST_QUERY","useEmailDataScope","LIST_SORT_HEADERS","subscribers","LIST_SELECT_FIELDS","OrgListsCard","props","hostId","basePath","firestore","router","enqueueSnackbar","confirm","data","user","scope","gridFilter","selectFields","askedSort","setAskedSort","sorts","rows","lists","hasMore","page","setPage","pageSize","setPageSize","plan","declaration","request","clauses","search","searchWords","sort","deps","idField","filtering","length","some","word","trim","refusals","refused","fields","headers","options","counts","setCounts","undefined","active","Promise","all","map","list","snapshot","$id","count","then","entries","Object","fromEntries","JSON","stringify","l","pageSorts","row","columnSort","defaultSort","onSortChange","orderBy","createOpen","setCreateOpen","createError","setCreateError","creating","setCreating","handleCreate","values","name","String","id","kind","createdAt","now","variant","persist","push","error","console","handleDelete","accepted","title","description","confirmationText","confirmationButtonProps","color","catch","response","method","body","scopeId","result","json","ok","Error","message","listHref","rowActions","key","label","icon","path","size","href","destructive","onClick","columns","field","headerName","flex","minWidth","renderCell","event","stopPropagation","valueGetter","_value","lastEvaluatedAt","toDate","toLocaleString","width","items","header","help","anchor","contentGutterX","contentGutterY","contentBordered","HeaderProps","action","disabled","spacing","open","onClose","submitLabel","onSubmit","errorSlot","severity","sx","mt","mb","includeDescription","onChange","setClauses","notices","aria-label","rowHeight","onOpen","_id","hideFooter","filterMode","filterModel","onFilterModelChange","quickFilter","noRowsLabel","rowCount","onPageChange","onPageSizeChange","displayName"],"mappings":"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"}
|
|
@@ -33,6 +33,7 @@ import { ceilingedWindow } from "@aglyn/tenant-feature-instance/hooks/host-colle
|
|
|
33
33
|
import { listQueryConstraints } from "@aglyn/tenant-feature-instance/hooks/use-list-query";
|
|
34
34
|
import { useFirestore, useFirestoreCollection, useHostResourceApi, useHostVersionApi } from "@aglyn/tenant-feature-instance";
|
|
35
35
|
import { Alert, Button, Chip, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, MenuItem, Stack, TextField, Typography } from "@mui/material";
|
|
36
|
+
import { sortListRows } from "@aglyn/shared-util-tools/list-query/list-column-sort";
|
|
36
37
|
import { collection, doc, limit, query, updateDoc } from "firebase/firestore";
|
|
37
38
|
import { useRouter } from "next/navigation";
|
|
38
39
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
@@ -80,6 +81,14 @@ const ORG_TEMPLATE_FILTER_HEADERS = _extends({}, EMAIL_TEMPLATE_FILTER_HEADERS,
|
|
|
80
81
|
// Several Site clauses all hold: a site must be named by each.
|
|
81
82
|
return named.reduce((kept, next)=>kept.filter((id)=>next.includes(id)));
|
|
82
83
|
}
|
|
84
|
+
/** How each header orders the matches in hand (AGL-3680). */ const ORG_TEMPLATE_SORTS = {
|
|
85
|
+
displayName: (row)=>{
|
|
86
|
+
var _row_displayName;
|
|
87
|
+
return String((_row_displayName = row['displayName']) != null ? _row_displayName : 'Untitled template');
|
|
88
|
+
},
|
|
89
|
+
site: (row)=>row['siteName'],
|
|
90
|
+
origin: (row)=>templateProvenance(row).origin === 'installed' ? 'installed' : 'local'
|
|
91
|
+
};
|
|
83
92
|
/**
|
|
84
93
|
* ONE SITE'S EMAIL TEMPLATES, read while the organization's list is on screen.
|
|
85
94
|
*
|
|
@@ -251,6 +260,20 @@ function OrgEmailTemplatesTable(props) {
|
|
|
251
260
|
}), [
|
|
252
261
|
mount
|
|
253
262
|
]);
|
|
263
|
+
/*
|
|
264
|
+
* EVERY HEADER SORTS (AGL-3680), over EVERY match in hand rather than the
|
|
265
|
+
* page on screen: the rows are all loaded (each site capped, and the cap
|
|
266
|
+
* says so above), so the sort is exact and the footer pages the sorted
|
|
267
|
+
* whole. Site sorts by the name the column shows, not the site id.
|
|
268
|
+
*/ const [sortModel, setSortModel] = useState([]);
|
|
269
|
+
const sorted = useMemo(()=>{
|
|
270
|
+
const [first] = sortModel;
|
|
271
|
+
const value = (first == null ? void 0 : first.sort) ? ORG_TEMPLATE_SORTS[first.field] : undefined;
|
|
272
|
+
return value && (first == null ? void 0 : first.sort) ? sortListRows(rows, value, first.sort) : rows;
|
|
273
|
+
}, [
|
|
274
|
+
rows,
|
|
275
|
+
sortModel
|
|
276
|
+
]);
|
|
254
277
|
const [page, setPage] = useState(0);
|
|
255
278
|
const [pageSize, setPageSize] = useState(TABLE_PAGE_SIZE_DEFAULT);
|
|
256
279
|
const planKey = JSON.stringify({
|
|
@@ -258,13 +281,15 @@ function OrgEmailTemplatesTable(props) {
|
|
|
258
281
|
orderBy: plan.orderBy
|
|
259
282
|
});
|
|
260
283
|
const siteKey = sites.map((site)=>site.id).join(',');
|
|
261
|
-
|
|
284
|
+
const sortKey = JSON.stringify(sortModel);
|
|
285
|
+
// A new answer, or a new order, starts again at its first page.
|
|
262
286
|
useEffect(()=>setPage(0), [
|
|
263
287
|
siteKey,
|
|
264
|
-
planKey
|
|
288
|
+
planKey,
|
|
289
|
+
sortKey
|
|
265
290
|
]);
|
|
266
|
-
const shown = useMemo(()=>
|
|
267
|
-
|
|
291
|
+
const shown = useMemo(()=>sorted.slice(page * pageSize, page * pageSize + pageSize), [
|
|
292
|
+
sorted,
|
|
268
293
|
page,
|
|
269
294
|
pageSize
|
|
270
295
|
]);
|
|
@@ -500,13 +525,15 @@ function OrgEmailTemplatesTable(props) {
|
|
|
500
525
|
rowHeight: TABLE_ROW_HEIGHT,
|
|
501
526
|
// Paged by the footer below, so the grid must not also slice.
|
|
502
527
|
hideFooter: true,
|
|
503
|
-
// The panel and the search go to every site's query;
|
|
504
|
-
//
|
|
528
|
+
// The panel and the search go to every site's query; a header
|
|
529
|
+
// sorts every match in hand before the footer pages it.
|
|
505
530
|
filterMode: "server",
|
|
506
531
|
filterModel: gridFilter.filterModel,
|
|
507
532
|
onFilterModelChange: gridFilter.onFilterModelChange,
|
|
508
533
|
quickFilter: true,
|
|
509
|
-
|
|
534
|
+
sortingMode: "server",
|
|
535
|
+
sortModel: sortModel,
|
|
536
|
+
onSortModelChange: setSortModel,
|
|
510
537
|
noRowsLabel: "No templates match these filters",
|
|
511
538
|
onOpen: (_id, row)=>{
|
|
512
539
|
const href = templateHref(row);
|