@aglyn/plugins-email 1.0.0-beta.199 → 1.0.0-beta.201

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/package.json +17 -17
  2. package/src/lib/components/email-screens-card.d.ts +8 -9
  3. package/src/lib/components/email-screens-card.js +83 -100
  4. package/src/lib/components/email-screens-card.js.map +1 -1
  5. package/src/lib/components/email-template-detail.js +3 -2
  6. package/src/lib/components/email-template-detail.js.map +1 -1
  7. package/src/lib/components/list-edit-card.js +2 -2
  8. package/src/lib/components/list-edit-card.js.map +1 -1
  9. package/src/lib/components/list-members-panel.js +72 -93
  10. package/src/lib/components/list-members-panel.js.map +1 -1
  11. package/src/lib/components/lists-card.js +70 -77
  12. package/src/lib/components/lists-card.js.map +1 -1
  13. package/src/lib/components/org-email-templates-card.d.ts +10 -6
  14. package/src/lib/components/org-email-templates-card.js +145 -90
  15. package/src/lib/components/org-email-templates-card.js.map +1 -1
  16. package/src/lib/components/suppressions-card.js +73 -69
  17. package/src/lib/components/suppressions-card.js.map +1 -1
  18. package/src/lib/constants/list-queries.d.ts +34 -0
  19. package/src/lib/constants/list-queries.js +256 -0
  20. package/src/lib/constants/list-queries.js.map +1 -0
  21. package/src/lib/server-email-drafts.js +6 -4
  22. package/src/lib/server-email-drafts.js.map +1 -1
  23. package/src/lib/server-suppressions.js +3 -1
  24. package/src/lib/server-suppressions.js.map +1 -1
  25. package/src/lib/server.js +3 -1
  26. package/src/lib/server.js.map +1 -1
  27. package/src/lib/utils/email-template-soft-delete.d.ts +39 -0
  28. package/src/lib/utils/email-template-soft-delete.js +41 -0
  29. package/src/lib/utils/email-template-soft-delete.js.map +1 -0
@@ -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 {\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 {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { inMemoryListField } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { TABLE_ROW_HEIGHT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport { usePagedRowsFilter } from '@aglyn/shared-ui-jsx/hooks/use-paged-rows-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 limit,\n orderBy,\n query,\n setDoc,\n} from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useEffect, useMemo, useState } from 'react'\nimport {\n useFirestore,\n usePagedCollection,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\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/*\n * What the lists grid's Filters panel offers (AGL-3317). The list is a paged\n * listener, so a filter matches over what its window has read, which the\n * first filter widens (`usePagedRowsFilter`). Membership reads `kindKey`,\n * the stored kind with a list that predates `kind` counted as manual, which\n * is how the column draws it.\n */\nconst LIST_FILTER_FIELDS = [\n inMemoryListField('name', 'text'),\n inMemoryListField('kind', 'select', 'kindKey'),\n]\nconst LIST_FILTER_HEADERS: Readonly<Record<string, string>> = {\n name: 'List',\n kind: 'Membership',\n}\nconst LIST_FILTER_OPTIONS = {\n kind: [\n { value: 'manual', label: 'Manual' },\n { value: 'dynamic', label: 'Rule' },\n ],\n}\nconst LIST_SEARCH_FIELDS = ['name'] as const\n\n/** A list row with the membership kind the filter matches on. */\nconst withKindKey = (list: any) => ({\n ...list,\n kindKey: list.kind === 'dynamic' ? 'dynamic' : 'manual',\n})\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 const {\n data: listData,\n rows: listRows,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n } = usePagedCollection<any>(\n (pageLimit) =>\n scope\n ? query(\n collection(firestore, scope[0], scope[1], 'lists'),\n orderBy('name'),\n limit(pageLimit),\n )\n : null,\n [firestore, scope],\n { idField: '$id' },\n )\n const listWindow = useMemo(() => listData?.map(withKindKey), [listData])\n const listPage = useMemo(() => listRows.map(withKindKey), [listRows])\n const listFilter = usePagedRowsFilter<any>(\n {\n data: listWindow,\n rows: listPage,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n },\n {\n fields: LIST_FILTER_FIELDS,\n options: LIST_FILTER_OPTIONS,\n headers: LIST_FILTER_HEADERS,\n search: LIST_SEARCH_FIELDS,\n },\n )\n // The rows on screen: the page, or the page of the matches.\n const lists = listFilter.rows\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 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 valueGetter: (_value, row) => row.kindKey,\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 {listRows.length === 0 && !hasMore && !listFilter.filtering ? null : (\n <>\n <ListFilterChips {...listFilter.chipsProps} />\n {listFilter.filtering && hasMore ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Filtering the ${listFilter.read} lists read so far — the next page reads more.`}\n </Typography>\n ) : null}\n <ListTable\n aria-label=\"Email lists\"\n rows={lists}\n columns={listFilter.filterColumns(columns)}\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 are the grid's; the card answers\n // them over what its window read.\n {...listFilter.gridProps}\n noRowsLabel=\"No lists match these filters\"\n />\n <ListPagination {...listFilter.pagination} />\n </>\n )}\n </Stack>\n </CardDisplay>\n )\n}\nOrgListsCard.displayName = 'OrgListsCard'\n\nexport default OrgListsCard\n"],"names":["createResourceUid","pluginDocsHelp","mdiDeleteOutline","mdiEyeOutline","mdiPencilOutline","AppLink","CardDisplay","MdiIcon","useConfirmationContext","ListFilterChips","ListPagination","ListRowActions","ListTable","listActionsColumn","inMemoryListField","TABLE_ROW_HEIGHT","usePagedRowsFilter","CreateArtifactDrawer","useSnackbar","Timestamp","Alert","Button","Chip","Stack","Typography","collection","doc","getCountFromServer","limit","orderBy","query","setDoc","useRouter","useEffect","useMemo","useState","useFirestore","usePagedCollection","useUser","authorizedFetch","useEmailDataScope","LIST_FILTER_FIELDS","LIST_FILTER_HEADERS","name","kind","LIST_FILTER_OPTIONS","value","label","LIST_SEARCH_FIELDS","withKindKey","list","kindKey","OrgListsCard","props","hostId","basePath","firestore","router","enqueueSnackbar","confirm","data","user","scope","listData","rows","listRows","hasMore","page","setPage","pageSize","setPageSize","pageLimit","idField","listWindow","map","listPage","listFilter","fields","options","headers","search","lists","counts","setCounts","undefined","active","Promise","all","snapshot","$id","count","then","entries","Object","fromEntries","JSON","stringify","l","createOpen","setCreateOpen","createError","setCreateError","creating","setCreating","handleCreate","values","String","trim","id","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","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","length","filtering","chipsProps","read","aria-label","filterColumns","rowHeight","onOpen","_id","hideFooter","gridProps","noRowsLabel","pagination","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 { 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,3 +1,4 @@
1
+ import { type ListFilterClause } from '@aglyn/shared-ui-jsx/const/list-grid-filter';
1
2
  /**
2
3
  * How many of one site's templates the organization's list reads.
3
4
  *
@@ -6,6 +7,8 @@
6
7
  * its own Templates page, which reads its whole set.
7
8
  */
8
9
  export declare const ORG_TEMPLATE_CEILING = 50;
10
+ /** The sites a Site clause names, or null when none is in force. */
11
+ export declare function orgTemplateSiteScope(clauses: readonly ListFilterClause[]): string[] | null;
9
12
  /**
10
13
  * THE ORGANIZATION'S TEMPLATES: every site's reusable email designs, in one
11
14
  * table.
@@ -17,12 +20,13 @@ export declare const ORG_TEMPLATE_CEILING = 50;
17
20
  * organization-level template page, because there is no such thing.
18
21
  *
19
22
  * The reads are one listener per site, over one page of sites at a time —
20
- * ordered by name — each capped at {@link ORG_TEMPLATE_CEILING} templates.
21
- * The shared footer pages the templates in hand, as a site's own list does;
22
- * an organization with more sites than one page chooses which page of sites
23
- * is read with the Sites filter above the table, and only that page's sites
24
- * are read. Both caps say so on screen when they bite, so a missing template
25
- * is never mistaken for one that does not exist.
23
+ * ordered by name — each its site's query for the clauses and search in
24
+ * force, capped at {@link ORG_TEMPLATE_CEILING} matches. The shared footer
25
+ * pages the matches in hand; an organization with more sites than one page
26
+ * chooses which page of sites is read with the Sites picker above the
27
+ * table, or names sites with the Site filter. Both caps say so on screen when
28
+ * they bite, so a missing template is never mistaken for one that does not
29
+ * exist.
26
30
  *
27
31
  * "New template" asks which site the design is made on — the besigner opens
28
32
  * on a site — and skips the question when the org has one site.
@@ -21,19 +21,25 @@ import { mdiDeleteOutline, mdiEyeOutline, mdiPencilOutline } from "@aglyn/shared
21
21
  import { AppLink, CardDisplay, MdiIcon, useConfirmationContext } from "@aglyn/shared-ui-jsx";
22
22
  import ListFilterChips from "@aglyn/shared-ui-jsx/components/list-filter-chips.component";
23
23
  import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
24
+ import ListQueryNotices, { listQueryRefusals } from "@aglyn/shared-ui-jsx/components/list-query-notices.component";
24
25
  import { ListRowActions, ListTable, listActionsColumn } from "@aglyn/shared-ui-jsx/components/list-table.component";
25
- import { inMemoryListField } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
26
- import { useListRowsFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-rows-filter";
26
+ import { listFilterGridColumns } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
27
+ import { planListQuery } from "@aglyn/shared-ui-jsx/const/list-query-plan";
28
+ import { useListGridFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-grid-filter";
29
+ import { nameSearchNormalizers } from "@aglyn/aglyn/app-utils/name-search";
27
30
  import { TABLE_PAGE_SIZE_DEFAULT, TABLE_ROW_HEIGHT } from "@aglyn/shared-ui-jsx/const/table-pagination";
28
31
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
29
- import { ceilingedWindow, collectionCeiling } from "@aglyn/tenant-feature-instance/hooks/host-collection-queries";
32
+ import { ceilingedWindow } from "@aglyn/tenant-feature-instance/hooks/host-collection-queries";
33
+ import { listQueryConstraints } from "@aglyn/tenant-feature-instance/hooks/use-list-query";
30
34
  import { useFirestore, useFirestoreCollection, useHostResourceApi, useHostVersionApi } from "@aglyn/tenant-feature-instance";
31
35
  import { Alert, Button, Chip, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, MenuItem, Stack, TextField, Typography } from "@mui/material";
32
- import { collection, doc, query, Timestamp, updateDoc, where } from "firebase/firestore";
36
+ import { collection, doc, limit, query, updateDoc } from "firebase/firestore";
33
37
  import { useRouter } from "next/navigation";
34
38
  import { useCallback, useEffect, useMemo, useState } from "react";
39
+ import { EMAIL_TEMPLATE_BASE, EMAIL_TEMPLATE_FILTER_HEADERS, EMAIL_TEMPLATE_QUERY } from "../constants/list-queries.js";
35
40
  import { templateProvenance } from "../model/template-provenance.js";
36
41
  import { createEmailScreen } from "../utils/create-email-screen.js";
42
+ import { emailTemplateSoftDelete } from "../utils/email-template-soft-delete.js";
37
43
  import { OrgSiteSelect, orgSiteEmailsPath, orgSiteName, orgSitePageLabel, useEmailOrgMount, useOrgSitePage } from "./email-org-mount.js";
38
44
  /**
39
45
  * How many of one site's templates the organization's list reads.
@@ -43,34 +49,37 @@ import { OrgSiteSelect, orgSiteEmailsPath, orgSiteName, orgSitePageLabel, useEma
43
49
  * its own Templates page, which reads its whole set.
44
50
  */ export const ORG_TEMPLATE_CEILING = 50;
45
51
  /*
46
- * What the organization's templates grid's Filters panel offers (AGL-3317).
47
- * The table holds every template its page of sites read, so it answers the
48
- * panel over all of them before the footer's slice. Site offers the sites on
49
- * that page; Origin reads `originKey`, the provenance the column draws.
50
- */ const ORG_TEMPLATE_FILTER_FIELDS = [
51
- inMemoryListField('displayName', 'text', 'templateName'),
52
- inMemoryListField('site', 'select', 'hostId'),
53
- inMemoryListField('origin', 'select', 'originKey')
54
- ];
55
- const ORG_TEMPLATE_FILTER_HEADERS = {
56
- displayName: 'Template',
57
- site: 'Site',
58
- origin: 'Origin'
52
+ * What the organization's templates grid's Filters panel offers (AGL-3321).
53
+ *
54
+ * The template clauses and the search are the site list's own
55
+ * (`EMAIL_TEMPLATE_QUERY`): ONE plan, put on EVERY site's query, so each
56
+ * site's read is already the answer and nothing is matched in the browser.
57
+ * Site is not a field of a screen but a choice of WHICH sites' queries run:
58
+ * a Site clause reads exactly the sites it names, whichever page of sites
59
+ * they are on. Origin (yours or installed) is not offered, for the reason the
60
+ * declaration gives: a query cannot ask for a field to be absent.
61
+ */ const SITE_FIELD = {
62
+ column: 'site',
63
+ kind: 'exact',
64
+ path: 'hostId',
65
+ operators: [
66
+ 'equals',
67
+ 'isAnyOf'
68
+ ]
59
69
  };
60
- const ORIGIN_OPTIONS = [
61
- {
62
- value: 'local',
63
- label: 'Yours'
64
- },
65
- {
66
- value: 'installed',
67
- label: 'Installed'
68
- }
69
- ];
70
- const ORG_TEMPLATE_SEARCH_FIELDS = [
71
- 'templateName',
72
- 'siteName'
70
+ const ORG_TEMPLATE_FILTER_FIELDS = [
71
+ ...EMAIL_TEMPLATE_QUERY.fields,
72
+ SITE_FIELD
73
73
  ];
74
+ const ORG_TEMPLATE_FILTER_HEADERS = _extends({}, EMAIL_TEMPLATE_FILTER_HEADERS, {
75
+ site: 'Site'
76
+ });
77
+ /** The sites a Site clause names, or null when none is in force. */ export function orgTemplateSiteScope(clauses) {
78
+ const named = clauses.filter((clause)=>clause.field === SITE_FIELD.column).map((clause)=>clause.value.split(',').map((entry)=>entry.trim()).filter(Boolean));
79
+ if (!named.length) return null;
80
+ // Several Site clauses all hold: a site must be named by each.
81
+ return named.reduce((kept, next)=>kept.filter((id)=>next.includes(id)));
82
+ }
74
83
  /**
75
84
  * ONE SITE'S EMAIL TEMPLATES, read while the organization's list is on screen.
76
85
  *
@@ -78,15 +87,22 @@ const ORG_TEMPLATE_SEARCH_FIELDS = [
78
87
  * changes and a hook's call count may not: each mounted reader is one
79
88
  * listener, and a page of sites mounts one per site on it.
80
89
  *
81
- * The equality filter is on the QUERY, so a site with a thousand pages reads
82
- * its emails and nothing else. `collectionCeiling` orders on the document
83
- * name, which an equality predicate composes onto without a composite index.
90
+ * Every predicate is on the QUERY — the email scope, every clause and the
91
+ * search word, one plan for all the sites (AGL-3321) — so a site's read is
92
+ * its first templates THAT MATCH, by name, and a site with a thousand pages
93
+ * reads its matching emails and nothing else. It reads one past the ceiling,
94
+ * so the table can say when a site holds more matches than it listed.
84
95
  */ function SiteTemplatesReader(props) {
85
- const { hostId, onRead } = props;
96
+ const { hostId, plan, onRead } = props;
86
97
  const firestore = useFirestore();
87
- const { data, status } = useFirestoreCollection(()=>collectionCeiling(query(collection(firestore, 'hosts', hostId, 'screens'), where('kind', '==', 'email')), ORG_TEMPLATE_CEILING), [
98
+ const planKey = JSON.stringify({
99
+ filters: plan.filters,
100
+ orderBy: plan.orderBy
101
+ });
102
+ const { data, status } = useFirestoreCollection(()=>query(collection(firestore, 'hosts', hostId, 'screens'), ...listQueryConstraints(plan), limit(ORG_TEMPLATE_CEILING + 1)), [
88
103
  firestore,
89
- hostId
104
+ hostId,
105
+ planKey
90
106
  ], {
91
107
  idField: '$id'
92
108
  });
@@ -121,12 +137,13 @@ SiteTemplatesReader.displayName = 'SiteTemplatesReader';
121
137
  * organization-level template page, because there is no such thing.
122
138
  *
123
139
  * The reads are one listener per site, over one page of sites at a time —
124
- * ordered by name — each capped at {@link ORG_TEMPLATE_CEILING} templates.
125
- * The shared footer pages the templates in hand, as a site's own list does;
126
- * an organization with more sites than one page chooses which page of sites
127
- * is read with the Sites filter above the table, and only that page's sites
128
- * are read. Both caps say so on screen when they bite, so a missing template
129
- * is never mistaken for one that does not exist.
140
+ * ordered by name — each its site's query for the clauses and search in
141
+ * force, capped at {@link ORG_TEMPLATE_CEILING} matches. The shared footer
142
+ * pages the matches in hand; an organization with more sites than one page
143
+ * chooses which page of sites is read with the Sites picker above the
144
+ * table, or names sites with the Site filter. Both caps say so on screen when
145
+ * they bite, so a missing template is never mistaken for one that does not
146
+ * exist.
130
147
  *
131
148
  * "New template" asks which site the design is made on — the besigner opens
132
149
  * on a site — and skips the question when the org has one site.
@@ -146,7 +163,38 @@ function OrgEmailTemplatesTable(props) {
146
163
  const createHostResource = useHostResourceApi();
147
164
  const createHostVersion = useHostVersionApi();
148
165
  const sitePage = useOrgSitePage(mount);
149
- const { sites } = sitePage;
166
+ const gridFilter = useListGridFilter({
167
+ selectFields: [
168
+ SITE_FIELD.column
169
+ ]
170
+ });
171
+ /*
172
+ * Which sites are read: the ones a Site clause names, else the page of
173
+ * sites. The other clauses and the search are ONE plan every site's query
174
+ * carries.
175
+ */ const siteScope = useMemo(()=>orgTemplateSiteScope(gridFilter.clauses), [
176
+ gridFilter.clauses
177
+ ]);
178
+ const sites = useMemo(()=>siteScope ? mount.hosts.filter((host)=>siteScope.includes(host.id)) : sitePage.sites, [
179
+ siteScope,
180
+ mount.hosts,
181
+ sitePage.sites
182
+ ]);
183
+ const plan = useMemo(()=>planListQuery(EMAIL_TEMPLATE_QUERY, {
184
+ clauses: gridFilter.clauses.filter((clause)=>clause.field !== SITE_FIELD.column),
185
+ search: gridFilter.searchWords,
186
+ base: EMAIL_TEMPLATE_BASE
187
+ }, nameSearchNormalizers), [
188
+ gridFilter.clauses,
189
+ gridFilter.searchWords
190
+ ]);
191
+ const filtering = gridFilter.clauses.length > 0 || gridFilter.searchWords.some((word)=>word.trim());
192
+ const refusals = useMemo(()=>listQueryRefusals(plan.refused, {
193
+ fields: ORG_TEMPLATE_FILTER_FIELDS,
194
+ headers: ORG_TEMPLATE_FILTER_HEADERS
195
+ }), [
196
+ plan.refused
197
+ ]);
150
198
  const [reads, setReads] = useState({});
151
199
  const onRead = useCallback((hostId, read)=>setReads((current)=>{
152
200
  const previous = current[hostId];
@@ -161,24 +209,21 @@ function OrgEmailTemplatesTable(props) {
161
209
  /*
162
210
  * One row per template, named by site. The row id joins the site to the
163
211
  * screen id: two sites can hold a screen with the same id, and a grid that
164
- * keyed on the screen alone would draw one of them twice.
212
+ * keyed on the screen alone would draw one of them twice. Every row is an
213
+ * answer some site's query gave; the sort only interleaves the sites'
214
+ * answers by the name each was already ordered by.
165
215
  */ const rows = useMemo(()=>sites.flatMap((site)=>{
166
216
  var _ref;
167
217
  var _reads_site_id;
168
- return ((_ref = (_reads_site_id = reads[site.id]) == null ? void 0 : _reads_site_id.rows) != null ? _ref : []).filter((screen)=>!screen['deletedAt']).map((screen)=>{
169
- var _screen_displayName;
170
- return _extends({}, screen, {
218
+ return ((_ref = (_reads_site_id = reads[site.id]) == null ? void 0 : _reads_site_id.rows) != null ? _ref : []).map((screen)=>_extends({}, screen, {
171
219
  $id: `${site.id}:${screen['$id']}`,
172
220
  screenId: String(screen['$id']),
173
221
  hostId: site.id,
174
- templateName: String((_screen_displayName = screen['displayName']) != null ? _screen_displayName : 'Untitled template'),
175
- siteName: orgSiteName(mount, site.id),
176
- originKey: templateProvenance(screen).origin === 'installed' ? 'installed' : 'local'
177
- });
178
- });
222
+ siteName: orgSiteName(mount, site.id)
223
+ }));
179
224
  }).sort((a, b)=>{
180
- var _a_displayName, _b_displayName;
181
- return String((_a_displayName = a['displayName']) != null ? _a_displayName : '').localeCompare(String((_b_displayName = b['displayName']) != null ? _b_displayName : '')) || orgSiteName(mount, a.hostId).localeCompare(orgSiteName(mount, b.hostId));
225
+ var _a_nameLower, _b_nameLower;
226
+ return String((_a_nameLower = a['nameLower']) != null ? _a_nameLower : '').localeCompare(String((_b_nameLower = b['nameLower']) != null ? _b_nameLower : '')) || orgSiteName(mount, a.hostId).localeCompare(orgSiteName(mount, b.hostId));
182
227
  }), [
183
228
  sites,
184
229
  reads,
@@ -193,38 +238,33 @@ function OrgEmailTemplatesTable(props) {
193
238
  return (_reads_site_id = reads[site.id]) == null ? void 0 : _reads_site_id.truncated;
194
239
  });
195
240
  /*
196
- * The templates in hand, a page at a time on the shared footer. Choosing
197
- * another page of sites starts the templates from their first page: page
198
- * four of one set of sites is not a position in another.
241
+ * The matches in hand, a page at a time on the shared footer. Choosing
242
+ * another page of sites, or another question, starts from the first page:
243
+ * page four of one answer is not a position in another.
199
244
  */ const filterOptions = useMemo(()=>({
200
- site: sites.map((site)=>({
245
+ // Every site the organization has: a Site clause reads the sites it
246
+ // names wherever they sit in the page of sites.
247
+ site: mount.hosts.map((site)=>({
201
248
  value: site.id,
202
249
  label: orgSiteName(mount, site.id)
203
- })),
204
- origin: ORIGIN_OPTIONS
250
+ }))
205
251
  }), [
206
- sites,
207
252
  mount
208
253
  ]);
209
- const templateFilter = useListRowsFilter({
210
- rows,
211
- fields: ORG_TEMPLATE_FILTER_FIELDS,
212
- options: filterOptions,
213
- headers: ORG_TEMPLATE_FILTER_HEADERS,
214
- search: ORG_TEMPLATE_SEARCH_FIELDS
215
- });
216
- const matched = templateFilter.rows;
217
254
  const [page, setPage] = useState(0);
218
255
  const [pageSize, setPageSize] = useState(TABLE_PAGE_SIZE_DEFAULT);
219
- const searchKey = templateFilter.gridFilter.searchWords.join(' ');
220
- // A narrowed list starts again at its first page.
256
+ const planKey = JSON.stringify({
257
+ filters: plan.filters,
258
+ orderBy: plan.orderBy
259
+ });
260
+ const siteKey = sites.map((site)=>site.id).join(',');
261
+ // A new answer starts again at its first page.
221
262
  useEffect(()=>setPage(0), [
222
- sitePage.page,
223
- templateFilter.gridFilter.clauses,
224
- searchKey
263
+ siteKey,
264
+ planKey
225
265
  ]);
226
- const shown = useMemo(()=>matched.slice(page * pageSize, page * pageSize + pageSize), [
227
- matched,
266
+ const shown = useMemo(()=>rows.slice(page * pageSize, page * pageSize + pageSize), [
267
+ rows,
228
268
  page,
229
269
  pageSize
230
270
  ]);
@@ -258,9 +298,7 @@ function OrgEmailTemplatesTable(props) {
258
298
  }
259
299
  }).then(()=>true).catch(()=>false);
260
300
  if (!confirmed) return;
261
- await updateDoc(doc(firestore, 'hosts', row['hostId'], 'screens', row['screenId']), {
262
- deletedAt: Timestamp.now()
263
- });
301
+ await updateDoc(doc(firestore, 'hosts', row['hostId'], 'screens', row['screenId']), emailTemplateSoftDelete());
264
302
  };
265
303
  const rowActions = (row)=>{
266
304
  const page = templateHref(row);
@@ -331,7 +369,7 @@ function OrgEmailTemplatesTable(props) {
331
369
  field: 'origin',
332
370
  headerName: 'Origin',
333
371
  width: 130,
334
- valueGetter: (_value, row)=>row.originKey,
372
+ valueGetter: (_value, row)=>templateProvenance(row).origin === 'installed' ? 'installed' : 'local',
335
373
  renderCell: ({ value })=>value === 'installed' ? /*#__PURE__*/ _jsx(Chip, {
336
374
  size: "small",
337
375
  label: "Installed"
@@ -408,12 +446,13 @@ function OrgEmailTemplatesTable(props) {
408
446
  children: [
409
447
  sites.map((site)=>/*#__PURE__*/ _jsx(SiteTemplatesReader, {
410
448
  hostId: site.id,
449
+ plan: plan,
411
450
  onRead: onRead
412
451
  }, site.id)),
413
452
  /*#__PURE__*/ _jsxs(Stack, {
414
453
  spacing: 1.5,
415
454
  children: [
416
- sitePageCount > 1 ? /*#__PURE__*/ _jsx(TextField, {
455
+ sitePageCount > 1 && !siteScope ? /*#__PURE__*/ _jsx(TextField, {
417
456
  select: true,
418
457
  size: "small",
419
458
  label: "Sites",
@@ -437,32 +476,48 @@ function OrgEmailTemplatesTable(props) {
437
476
  variant: "body2",
438
477
  color: "text.secondary",
439
478
  children: 'This organization has no sites yet. A template is designed on ' + 'a site, in its besigner, so it appears here once there is one.'
440
- }) : rows.length === 0 ? /*#__PURE__*/ _jsx(Typography, {
479
+ }) : rows.length === 0 && !filtering ? /*#__PURE__*/ _jsx(Typography, {
441
480
  variant: "body2",
442
481
  color: "text.secondary",
443
482
  children: settled ? 'None of these sites has an email template yet. Design one ' + 'here, then send it from a campaign — a new template opens in ' + 'the besigner with email-safe components only.' : 'Loading templates…'
444
483
  }) : /*#__PURE__*/ _jsxs(_Fragment, {
445
484
  children: [
446
- /*#__PURE__*/ _jsx(ListFilterChips, _extends({}, templateFilter.chipsProps)),
447
- /*#__PURE__*/ _jsx(ListTable, _extends({
485
+ /*#__PURE__*/ _jsx(ListFilterChips, {
486
+ fields: ORG_TEMPLATE_FILTER_FIELDS,
487
+ headers: ORG_TEMPLATE_FILTER_HEADERS,
488
+ clauses: gridFilter.clauses,
489
+ onChange: gridFilter.setClauses,
490
+ options: filterOptions
491
+ }),
492
+ /*#__PURE__*/ _jsx(ListQueryNotices, {
493
+ refused: refusals,
494
+ notices: plan.notices
495
+ }),
496
+ /*#__PURE__*/ _jsx(ListTable, {
448
497
  "aria-label": "Email templates",
449
498
  rows: shown,
450
- columns: templateFilter.filterColumns(columns),
499
+ columns: listFilterGridColumns(columns, ORG_TEMPLATE_FILTER_FIELDS, filterOptions, ORG_TEMPLATE_FILTER_HEADERS),
451
500
  rowHeight: TABLE_ROW_HEIGHT,
452
501
  // Paged by the footer below, so the grid must not also slice.
453
- hideFooter: true
454
- }, templateFilter.gridProps, {
502
+ hideFooter: true,
503
+ // The panel and the search go to every site's query; the grid
504
+ // neither filters nor sorts the rows it holds.
505
+ filterMode: "server",
506
+ filterModel: gridFilter.filterModel,
507
+ onFilterModelChange: gridFilter.onFilterModelChange,
508
+ quickFilter: true,
509
+ disableColumnSorting: true,
455
510
  noRowsLabel: "No templates match these filters",
456
511
  onOpen: (_id, row)=>{
457
512
  const href = templateHref(row);
458
513
  if (href) router.push(href);
459
514
  }
460
- })),
515
+ }),
461
516
  /*#__PURE__*/ _jsx(ListPagination, {
462
517
  page: page,
463
518
  pageSize: pageSize,
464
519
  rowCount: shown.length,
465
- count: matched.length,
520
+ count: rows.length,
466
521
  onPageChange: setPage,
467
522
  onPageSizeChange: setPageSize
468
523
  })
@@ -473,7 +528,7 @@ function OrgEmailTemplatesTable(props) {
473
528
  return /*#__PURE__*/ _jsxs(Alert, {
474
529
  severity: "info",
475
530
  children: [
476
- `${orgSiteName(mount, site.id)} has more than ` + `${ORG_TEMPLATE_CEILING} templates, and only the first of them ` + 'are listed here. ',
531
+ `${orgSiteName(mount, site.id)} has more than ` + `${ORG_TEMPLATE_CEILING} ${filtering ? 'matching templates' : 'templates'}, ` + 'and only the first of them are listed here. ',
477
532
  href ? /*#__PURE__*/ _jsx(AppLink, {
478
533
  href: href,
479
534
  children: 'See all of its templates'