@aglyn/plugins-email 1.0.0-beta.198 → 1.0.0-beta.200
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +17 -17
- package/src/lib/components/email-screens-card.d.ts +8 -9
- package/src/lib/components/email-screens-card.js +83 -100
- package/src/lib/components/email-screens-card.js.map +1 -1
- package/src/lib/components/email-template-detail.js +3 -2
- package/src/lib/components/email-template-detail.js.map +1 -1
- package/src/lib/components/list-edit-card.js +2 -2
- package/src/lib/components/list-edit-card.js.map +1 -1
- package/src/lib/components/list-members-panel.js +72 -93
- package/src/lib/components/list-members-panel.js.map +1 -1
- package/src/lib/components/lists-card.js +70 -77
- package/src/lib/components/lists-card.js.map +1 -1
- package/src/lib/components/org-email-templates-card.d.ts +10 -6
- package/src/lib/components/org-email-templates-card.js +145 -90
- package/src/lib/components/org-email-templates-card.js.map +1 -1
- package/src/lib/components/suppressions-card.js +73 -69
- package/src/lib/components/suppressions-card.js.map +1 -1
- package/src/lib/constants/list-queries.d.ts +34 -0
- package/src/lib/constants/list-queries.js +256 -0
- package/src/lib/constants/list-queries.js.map +1 -0
- package/src/lib/server-email-drafts.js +3 -4
- package/src/lib/server-email-drafts.js.map +1 -1
- package/src/lib/server-suppressions.js +3 -1
- package/src/lib/server-suppressions.js.map +1 -1
- package/src/lib/server.js +3 -1
- package/src/lib/server.js.map +1 -1
- package/src/lib/utils/email-template-soft-delete.d.ts +39 -0
- package/src/lib/utils/email-template-soft-delete.js +41 -0
- 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
|
|
21
|
-
*
|
|
22
|
-
* an organization with more sites than one page
|
|
23
|
-
* is read with the Sites
|
|
24
|
-
*
|
|
25
|
-
* is never mistaken for one that does not
|
|
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 {
|
|
26
|
-
import {
|
|
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
|
|
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,
|
|
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-
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
|
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
|
|
125
|
-
*
|
|
126
|
-
* an organization with more sites than one page
|
|
127
|
-
* is read with the Sites
|
|
128
|
-
*
|
|
129
|
-
* is never mistaken for one that does not
|
|
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
|
|
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 : []).
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
originKey: templateProvenance(screen).origin === 'installed' ? 'installed' : 'local'
|
|
177
|
-
});
|
|
178
|
-
});
|
|
222
|
+
siteName: orgSiteName(mount, site.id)
|
|
223
|
+
}));
|
|
179
224
|
}).sort((a, b)=>{
|
|
180
|
-
var
|
|
181
|
-
return String((
|
|
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
|
|
197
|
-
* another page of sites
|
|
198
|
-
* four of one
|
|
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
|
|
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
|
|
220
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
searchKey
|
|
263
|
+
siteKey,
|
|
264
|
+
planKey
|
|
225
265
|
]);
|
|
226
|
-
const shown = useMemo(()=>
|
|
227
|
-
|
|
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.
|
|
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,
|
|
447
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
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'
|