@aglyn/plugins-forms 1.0.0-beta.200 → 1.0.0-beta.202

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.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/forms/src/lib/components/form-metrics-card.component.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 type { FormFieldDecl, FormStats } from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport {\n Figure,\n measuredRate,\n RateRow,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { Divider, Stack, Tooltip, Typography } from '@mui/material'\nimport { formPeriodSeries, formStatsWindow, pluginDocsHelp } from '@aglyn/aglyn'\nimport { useMemo } from 'react'\n\nexport interface FormMetricsCardProps {\n /** The stored counters, or `undefined` while the document is in flight. */\n stats?: FormStats\n /** The PUBLISHED declaration — what a submission to this form can carry. */\n fields: FormFieldDecl[]\n /** Whether this form routes a submission to a lead. */\n leadRouting: boolean\n /** The form document has not arrived yet, as against having no counters. */\n loading?: boolean\n}\n\n/**\n * A number, or `null` when the field carries no number at all.\n *\n * `?? 0` is the defect this exists to avoid: a counter that has never been\n * written and a counter standing at zero are different facts, and only one of\n * them is a measurement. Everything on this card goes through here.\n */\nfunction recorded(value: unknown): number | null {\n return typeof value === 'number' && Number.isFinite(value) ? value : null\n}\n\n/**\n * ONE FORM, MEASURED — and every rate naming its denominator.\n *\n * ## Where every number here comes from\n *\n * Counters on the form document, each incremented on a write that was\n * happening anyway. The alternative — counting `formSubmissions` when a\n * console surface renders — reads the one collection that grows without bound\n * and the one the customer is billed on, and it is the shape this product has\n * created repeatedly.\n *\n * - `stats.submissions` / `stats.leads` — `/api/forms/submit`, on the single\n * `update` it already issues per submission. `leads` counts submissions\n * this form FILED to the site's Leads; `addHostLead` keys one person to\n * one document, so a returning visitor's second submission is a second\n * capture and not a second person.\n * - `stats.views` / `stats.starts` — `/api/analytics/collect`, from the\n * form's own beacon, in the same shape the overlay counters use.\n * - `stats.periods` — all four again, keyed by month, riding the same\n * writes. This is the series drawn below AND the reason the rates can be\n * taken at all (see next).\n *\n * ## Why the rates are not lifetime over lifetime\n *\n * `submissions` has counted since the form entity existed; `views` only since\n * the beacon shipped. Dividing the lifetime totals would answer \"submissions\n * ever, over views since the day we started looking\" — arbitrarily wrong, and\n * wrong in the flattering direction. `formStatsWindow` sums both counters\n * over exactly the months the denominator was live for, and answers zero\n * periods when there are none, which renders as a dash.\n *\n * ## The view count is a CLIENT count, and this card says so\n *\n * A view is a beacon; a submission is a server write. A blocked beacon, a\n * browser that runs no script, a visitor who leaves before the request goes\n * out — each is a view that is missing from the denominator and a submission\n * that is present in the numerator. So a completion rate here can exceed\n * 100%, and it is printed as measured rather than clamped: a rate pinned at a\n * tidy 100% would read as a form everybody finishes.\n *\n * ## What is still NOT measured, and why\n *\n * An absence with a reason is a smaller lie than a number with none.\n *\n * - **Per-field answer rates.** The values live on the submission documents\n * in the Inbox, and counting them is the unbounded read every counter here\n * exists to avoid.\n * - **Anything before a counter started.** The series begins at the first\n * month with a record rather than padding backwards with zeros, and the\n * rates cover only the months their denominator was live.\n */\nexport function FormMetricsCard(props: FormMetricsCardProps) {\n const { stats, fields, leadRouting, loading } = props\n const submissions = recorded(stats?.submissions)\n const leads = recorded(stats?.leads)\n const views = recorded(stats?.views)\n const lastAt = recorded(stats?.lastSubmissionAtMs)\n\n const series = useMemo(() => formPeriodSeries(stats), [stats])\n /** The tallest month, which every bar below is drawn as a fraction of. */\n const peak = useMemo(\n () => series.reduce((high, point) => Math.max(high, point.submissions), 0),\n [series],\n )\n\n /*\n * `measuredRate` returns `null` on an unrecorded numerator OR a zero\n * denominator, which is both halves of the rule at once: a form nobody has\n * submitted to has no rate, and a form whose leads were never counted has\n * no rate either. Neither is `0%`.\n *\n * The denominator is named `submissions` and not \"people\" on purpose. A\n * lead is filed only from a submission that carried an address, so a rate\n * over every submission and a rate over the addressed ones are different\n * numbers — and this is the one the counter actually produces.\n */\n const leadRate = measuredRate(leads, submissions, 'submissions')\n /*\n * COMPLETION AND ABANDONMENT, each over the months its own denominator was\n * recorded in — never over the lifetime totals, and never over each\n * other's window. Views and starts began being counted on the same deploy\n * but they do not stay in step: a form nobody typed into has views and no\n * starts, so a shared window would silently take the abandonment rate over\n * months that recorded no start at all.\n */\n const viewWindow = formStatsWindow(stats, 'views', 'submissions')\n const startWindow = formStatsWindow(stats, 'starts', 'submissions')\n const completionRate = viewWindow.periods\n ? measuredRate(viewWindow.of, viewWindow.over, 'views')\n : null\n /*\n * Abandonment is the starts that did NOT arrive, and it is withheld — not\n * clamped — when there are more submissions than starts.\n *\n * That happens for a real reason rather than as an arithmetic edge: the\n * start is a beacon and the submission is a server write, so a visitor\n * whose beacon was blocked submits without ever having started. Clamping\n * the difference at zero would publish \"nobody abandons this form\" out of a\n * measurement that had gone incoherent, which is the loudest possible\n * version of the mistake this whole card is written against.\n */\n const abandonRate =\n startWindow.periods && startWindow.over >= startWindow.of\n ? measuredRate(\n startWindow.over - startWindow.of,\n startWindow.over,\n 'starts',\n )\n : null\n\n return (\n <CardDisplay\n header=\"What this form has collected\"\n help={pluginDocsHelp('forms', {\n anchor: '#one-forms-own-page',\n excerpt:\n 'Views and starts are counted in the visitor’s browser and ' +\n 'submissions on the server, and each rate covers only the months ' +\n 'its denominator was recorded.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap', rowGap: 2 }}>\n <Figure\n label=\"Views\"\n value={loading ? null : views}\n note=\"times this form was rendered\"\n />\n <Figure\n label=\"Submissions\"\n value={loading ? null : submissions}\n note=\"since this form was created\"\n />\n <Figure\n label=\"Lead captures\"\n value={leads}\n note=\"submissions filed to Leads\"\n />\n <Figure\n label=\"Declared fields\"\n value={fields.length}\n note=\"on the published design\"\n />\n </Stack>\n <Divider />\n <Section title=\"Rates\">\n <RateRow label=\"Views that became a submission\" rate={completionRate} />\n <RateRow label=\"Started and never submitted\" rate={abandonRate} />\n <RateRow label=\"Submissions that became a lead\" rate={leadRate} />\n {!leadRouting ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This form does not route to leads, so there is nothing for ' +\n 'that rate to be over.'}\n </Typography>\n ) : null}\n {/*\n The measurement's own shape, on the surface rather than in a\n docblock only a maintainer reads. Both facts change how the\n numbers above should be read: which months they cover, and that\n one side of them is counted in a browser.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Views and starts are counted in the visitor’s browser and ' +\n 'submissions on the server, so a blocked request is a view ' +\n 'this misses and a submission it does not. Each rate covers ' +\n 'only the months its denominator was being recorded.'}\n </Typography>\n </Section>\n <Divider />\n <Section title=\"Submissions by month\">\n {series.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Nothing recorded yet. The series starts at the first month ' +\n 'this form collects something.'}\n </Typography>\n ) : (\n /*\n A bar per month, drawn from the counters themselves.\n Deliberately starting at the first RECORDED month rather than a\n rolling twelve: a month before the counter existed would draw as\n a confident zero, which is a claim nobody measured.\n */\n <Stack spacing={0.5}>\n {series.map((point) => (\n <Stack\n key={point.period}\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center' }}\n >\n <Typography\n variant=\"caption\"\n color=\"text.secondary\"\n sx={{ minWidth: 64 }}\n >\n {point.period}\n </Typography>\n <Tooltip\n title={`${point.submissions.toLocaleString()} submissions${\n point.views\n ? `, ${point.views.toLocaleString()} views`\n : ''\n }`}\n >\n <Stack\n sx={{\n height: 8,\n borderRadius: 1,\n bgcolor: 'primary.main',\n // Zero keeps a hairline rather than vanishing: a row\n // with no bar at all reads as a missing month, and a\n // missing month is the one thing this series must\n // never be confused with.\n minWidth: 2,\n width: peak\n ? `${Math.round((point.submissions / peak) * 100)}%`\n : 2,\n }}\n />\n </Tooltip>\n <Typography variant=\"caption\" sx={{ minWidth: 40 }}>\n {point.submissions.toLocaleString()}\n </Typography>\n </Stack>\n ))}\n </Stack>\n )}\n </Section>\n <Divider />\n <Section title=\"Most recent\">\n <Typography variant=\"body2\">\n {lastAt === null\n ? 'No submission has been recorded for this form.'\n : new Date(lastAt).toLocaleString()}\n </Typography>\n </Section>\n <Divider />\n <Section title=\"Not measured\">\n {/*\n Named rather than omitted. A report that quietly leaves out the\n question a reader came with is indistinguishable from one that\n answered it badly.\n */}\n <Stack component=\"ul\" sx={{ pl: 2, m: 0 }} spacing={0.5}>\n <Typography component=\"li\" variant=\"caption\" color=\"text.secondary\">\n {'Per-field answer rates — which questions people skip. The ' +\n 'values live on the submission documents, and counting them ' +\n 'is a read of the whole collection every time this page ' +\n 'opens.'}\n </Typography>\n <Typography component=\"li\" variant=\"caption\" color=\"text.secondary\">\n {'Anything before a counter started. The month series begins ' +\n 'where the record begins rather than padding backwards with ' +\n 'zeros, and a rate is taken only over the months its ' +\n 'denominator was live.'}\n </Typography>\n <Typography component=\"li\" variant=\"caption\" color=\"text.secondary\">\n {'Who abandoned, or where they stopped. A start is one signal ' +\n 'per form, not per field, so this says how often a form was ' +\n 'left and never which question lost the visitor.'}\n </Typography>\n </Stack>\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nFormMetricsCard.displayName = 'FormMetricsCard'\n\nexport default FormMetricsCard\n"],"names":["CardDisplay","Figure","measuredRate","RateRow","Section","Divider","Stack","Tooltip","Typography","formPeriodSeries","formStatsWindow","pluginDocsHelp","useMemo","recorded","value","Number","isFinite","FormMetricsCard","props","stats","fields","leadRouting","loading","submissions","leads","views","lastAt","lastSubmissionAtMs","series","peak","reduce","high","point","Math","max","leadRate","viewWindow","startWindow","completionRate","periods","of","over","abandonRate","header","help","anchor","excerpt","contentGutterX","contentGutterY","spacing","direction","sx","flexWrap","rowGap","label","note","length","title","rate","variant","color","map","alignItems","minWidth","period","toLocaleString","height","borderRadius","bgcolor","width","round","Date","component","pl","m","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/forms/src/lib/components/form-metrics-card.component.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 type { FormFieldDecl, FormStats } from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport {\n Figure,\n measuredRate,\n RateRow,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { Divider, Stack, Tooltip, Typography } from '@mui/material'\nimport { formPeriodSeries, formStatsWindow, pluginDocsHelp } from '@aglyn/aglyn'\nimport { useMemo } from 'react'\n\nexport interface FormMetricsCardProps {\n /** The stored counters, or `undefined` while the document is in flight. */\n stats?: FormStats\n /** The PUBLISHED declaration — what a submission to this form can carry. */\n fields: FormFieldDecl[]\n /** Whether this form routes a submission to a lead. */\n leadRouting: boolean\n /** The form document has not arrived yet, as against having no counters. */\n loading?: boolean\n}\n\n/**\n * A number, or `null` when the field carries no number at all.\n *\n * `?? 0` is the defect this exists to avoid: a counter that has never been\n * written and a counter standing at zero are different facts, and only one of\n * them is a measurement. Everything on this card goes through here.\n */\nfunction recorded(value: unknown): number | null {\n return typeof value === 'number' && Number.isFinite(value) ? value : null\n}\n\n/**\n * ONE FORM, MEASURED — and every rate naming its denominator.\n *\n * ## Where every number here comes from\n *\n * Counters on the form document, each incremented on a write that was\n * happening anyway. The alternative — counting `formSubmissions` when a\n * console surface renders — reads the one collection that grows without bound\n * and the one the customer is billed on, and it is the shape this product has\n * created repeatedly.\n *\n * - `stats.submissions` / `stats.leads` — `/api/forms/submit`, on the single\n * `update` it already issues per submission, and recounted from their rows\n * after a removal (AGL-3330). `leads` counts the PEOPLE this form filed to\n * the Leads list that are still there: `addHostLead` keys one person to one\n * document, so a returning visitor's second submission is a second\n * submission and not a second lead.\n * - `stats.views` / `stats.starts` — `/api/analytics/collect`, from the\n * form's own beacon, in the same shape the overlay counters use.\n * - `stats.periods` — all four again, keyed by month, riding the same\n * writes. This is the series drawn below AND the reason the rates can be\n * taken at all (see next).\n *\n * ## Why the rates are not lifetime over lifetime\n *\n * `submissions` has counted since the form entity existed; `views` only since\n * the beacon shipped. Dividing the lifetime totals would answer \"submissions\n * ever, over views since the day we started looking\" — arbitrarily wrong, and\n * wrong in the flattering direction. `formStatsWindow` sums both counters\n * over exactly the months the denominator was live for, and answers zero\n * periods when there are none, which renders as a dash.\n *\n * ## The view count is a CLIENT count, and this card says so\n *\n * A view is a beacon; a submission is a server write. A blocked beacon, a\n * browser that runs no script, a visitor who leaves before the request goes\n * out — each is a view that is missing from the denominator and a submission\n * that is present in the numerator. So a completion rate here can exceed\n * 100%, and it is printed as measured rather than clamped: a rate pinned at a\n * tidy 100% would read as a form everybody finishes.\n *\n * ## What is still NOT measured, and why\n *\n * An absence with a reason is a smaller lie than a number with none.\n *\n * - **Per-field answer rates.** The values live on the submission documents\n * in the Inbox, and counting them is the unbounded read every counter here\n * exists to avoid.\n * - **Anything before a counter started.** The series begins at the first\n * month with a record rather than padding backwards with zeros, and the\n * rates cover only the months their denominator was live.\n */\nexport function FormMetricsCard(props: FormMetricsCardProps) {\n const { stats, fields, leadRouting, loading } = props\n const submissions = recorded(stats?.submissions)\n const leads = recorded(stats?.leads)\n const views = recorded(stats?.views)\n const lastAt = recorded(stats?.lastSubmissionAtMs)\n\n const series = useMemo(() => formPeriodSeries(stats), [stats])\n /** The tallest month, which every bar below is drawn as a fraction of. */\n const peak = useMemo(\n () => series.reduce((high, point) => Math.max(high, point.submissions), 0),\n [series],\n )\n\n /*\n * `measuredRate` returns `null` on an unrecorded numerator OR a zero\n * denominator, which is both halves of the rule at once: a form nobody has\n * submitted to has no rate, and a form whose leads were never counted has\n * no rate either. Neither is `0%`.\n *\n * The denominator is named `submissions` and not \"people\" on purpose. A\n * lead is filed only from a submission that carried an address, so a rate\n * over every submission and a rate over the addressed ones are different\n * numbers — and this is the one the counter actually produces.\n */\n const leadRate = measuredRate(leads, submissions, 'submissions')\n /*\n * COMPLETION AND ABANDONMENT, each over the months its own denominator was\n * recorded in — never over the lifetime totals, and never over each\n * other's window. Views and starts began being counted on the same deploy\n * but they do not stay in step: a form nobody typed into has views and no\n * starts, so a shared window would silently take the abandonment rate over\n * months that recorded no start at all.\n */\n const viewWindow = formStatsWindow(stats, 'views', 'submissions')\n const startWindow = formStatsWindow(stats, 'starts', 'submissions')\n const completionRate = viewWindow.periods\n ? measuredRate(viewWindow.of, viewWindow.over, 'views')\n : null\n /*\n * Abandonment is the starts that did NOT arrive, and it is withheld — not\n * clamped — when there are more submissions than starts.\n *\n * That happens for a real reason rather than as an arithmetic edge: the\n * start is a beacon and the submission is a server write, so a visitor\n * whose beacon was blocked submits without ever having started. Clamping\n * the difference at zero would publish \"nobody abandons this form\" out of a\n * measurement that had gone incoherent, which is the loudest possible\n * version of the mistake this whole card is written against.\n */\n const abandonRate =\n startWindow.periods && startWindow.over >= startWindow.of\n ? measuredRate(\n startWindow.over - startWindow.of,\n startWindow.over,\n 'starts',\n )\n : null\n\n return (\n <CardDisplay\n header=\"What this form has collected\"\n help={pluginDocsHelp('forms', {\n anchor: '#one-forms-own-page',\n excerpt:\n 'Views and starts are counted in the visitor’s browser and ' +\n 'submissions on the server, and each rate covers only the months ' +\n 'its denominator was recorded.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap', rowGap: 2 }}>\n <Figure\n label=\"Views\"\n value={loading ? null : views}\n note=\"times this form was rendered\"\n />\n <Figure\n label=\"Submissions\"\n value={loading ? null : submissions}\n note=\"since this form was created\"\n />\n <Figure\n label=\"Leads\"\n value={leads}\n note=\"people this form filed to Leads\"\n />\n <Figure\n label=\"Declared fields\"\n value={fields.length}\n note=\"on the published design\"\n />\n </Stack>\n <Divider />\n <Section title=\"Rates\">\n <RateRow label=\"Views that became a submission\" rate={completionRate} />\n <RateRow label=\"Started and never submitted\" rate={abandonRate} />\n <RateRow label=\"Submissions that became a lead\" rate={leadRate} />\n {!leadRouting ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This form does not route to leads, so there is nothing for ' +\n 'that rate to be over.'}\n </Typography>\n ) : null}\n {/*\n The measurement's own shape, on the surface rather than in a\n docblock only a maintainer reads. Both facts change how the\n numbers above should be read: which months they cover, and that\n one side of them is counted in a browser.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Views and starts are counted in the visitor’s browser and ' +\n 'submissions on the server, so a blocked request is a view ' +\n 'this misses and a submission it does not. Each rate covers ' +\n 'only the months its denominator was being recorded.'}\n </Typography>\n </Section>\n <Divider />\n <Section title=\"Submissions by month\">\n {series.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Nothing recorded yet. The series starts at the first month ' +\n 'this form collects something.'}\n </Typography>\n ) : (\n /*\n A bar per month, drawn from the counters themselves.\n Deliberately starting at the first RECORDED month rather than a\n rolling twelve: a month before the counter existed would draw as\n a confident zero, which is a claim nobody measured.\n */\n <Stack spacing={0.5}>\n {series.map((point) => (\n <Stack\n key={point.period}\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center' }}\n >\n <Typography\n variant=\"caption\"\n color=\"text.secondary\"\n sx={{ minWidth: 64 }}\n >\n {point.period}\n </Typography>\n <Tooltip\n title={`${point.submissions.toLocaleString()} submissions${\n point.views\n ? `, ${point.views.toLocaleString()} views`\n : ''\n }`}\n >\n <Stack\n sx={{\n height: 8,\n borderRadius: 1,\n bgcolor: 'primary.main',\n // Zero keeps a hairline rather than vanishing: a row\n // with no bar at all reads as a missing month, and a\n // missing month is the one thing this series must\n // never be confused with.\n minWidth: 2,\n width: peak\n ? `${Math.round((point.submissions / peak) * 100)}%`\n : 2,\n }}\n />\n </Tooltip>\n <Typography variant=\"caption\" sx={{ minWidth: 40 }}>\n {point.submissions.toLocaleString()}\n </Typography>\n </Stack>\n ))}\n </Stack>\n )}\n </Section>\n <Divider />\n <Section title=\"Most recent\">\n <Typography variant=\"body2\">\n {lastAt === null\n ? 'No submission has been recorded for this form.'\n : new Date(lastAt).toLocaleString()}\n </Typography>\n </Section>\n <Divider />\n <Section title=\"Not measured\">\n {/*\n Named rather than omitted. A report that quietly leaves out the\n question a reader came with is indistinguishable from one that\n answered it badly.\n */}\n <Stack component=\"ul\" sx={{ pl: 2, m: 0 }} spacing={0.5}>\n <Typography component=\"li\" variant=\"caption\" color=\"text.secondary\">\n {'Per-field answer rates — which questions people skip. The ' +\n 'values live on the submission documents, and counting them ' +\n 'is a read of the whole collection every time this page ' +\n 'opens.'}\n </Typography>\n <Typography component=\"li\" variant=\"caption\" color=\"text.secondary\">\n {'Anything before a counter started. The month series begins ' +\n 'where the record begins rather than padding backwards with ' +\n 'zeros, and a rate is taken only over the months its ' +\n 'denominator was live.'}\n </Typography>\n <Typography component=\"li\" variant=\"caption\" color=\"text.secondary\">\n {'Who abandoned, or where they stopped. A start is one signal ' +\n 'per form, not per field, so this says how often a form was ' +\n 'left and never which question lost the visitor.'}\n </Typography>\n </Stack>\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nFormMetricsCard.displayName = 'FormMetricsCard'\n\nexport default FormMetricsCard\n"],"names":["CardDisplay","Figure","measuredRate","RateRow","Section","Divider","Stack","Tooltip","Typography","formPeriodSeries","formStatsWindow","pluginDocsHelp","useMemo","recorded","value","Number","isFinite","FormMetricsCard","props","stats","fields","leadRouting","loading","submissions","leads","views","lastAt","lastSubmissionAtMs","series","peak","reduce","high","point","Math","max","leadRate","viewWindow","startWindow","completionRate","periods","of","over","abandonRate","header","help","anchor","excerpt","contentGutterX","contentGutterY","spacing","direction","sx","flexWrap","rowGap","label","note","length","title","rate","variant","color","map","alignItems","minWidth","period","toLocaleString","height","borderRadius","bgcolor","width","round","Date","component","pl","m","displayName"],"mappings":"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"}
@@ -43,11 +43,13 @@ export interface HostFormsCardProps {
43
43
  * ## The two numeric columns, and when one is honestly blank
44
44
  *
45
45
  * `stats.submissions` and `stats.leads` are both incremented by
46
- * `/api/forms/submit` on a write that was happening anyway, so both are real
47
- * numbers. `leads` is counted only for a form whose `routing.lead` is set, so
48
- * a form that does not route to leads carries no figure at all and renders as
49
- * a dash rather than as `0`. A zero would say this form has produced no leads,
50
- * which for an unrouted form is a measurement nobody took.
46
+ * `/api/forms/submit` on a write that was happening anyway, and recounted
47
+ * from their rows after a removal (AGL-3330), so both are real numbers.
48
+ * `leads` is counted only for a form whose `routing.lead` is set — it holds
49
+ * `0` there until the first lead — so a form that has never routed to leads
50
+ * carries no figure at all and renders as a dash rather than as `0`. A zero
51
+ * would say this form has produced no leads, which for an unrouted form is a
52
+ * measurement nobody took.
51
53
  */
52
54
  export declare function HostFormsCard(props: HostFormsCardProps): import("react").JSX.Element;
53
55
  export declare namespace HostFormsCard {
@@ -32,6 +32,7 @@ import { TABLE_ROW_HEIGHT } from "@aglyn/shared-ui-jsx/const/table-pagination";
32
32
  import { useListGridFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-grid-filter";
33
33
  import QuotaReadoutComponent from "@aglyn/shared-ui-jsx/components/quota-readout.component";
34
34
  import { CreateArtifactDrawer } from "@aglyn/shared-ui-jsx-forms";
35
+ import { Timestamp } from "@aglyn/shared-util-timestamp";
35
36
  import { Alert, Button, Stack, Typography } from "@mui/material";
36
37
  import { collection, doc, getCountFromServer, query, updateDoc, where } from "firebase/firestore";
37
38
  import { DUPLICATE_MENU_LABEL, useConsoleHostRoute, useDuplicateResource, useFirestore, useHostCampaigns, useHostResourceApi, useLiveArtifactCount } from "@aglyn/tenant-feature-instance";
@@ -39,10 +40,10 @@ import { useListQuery } from "@aglyn/tenant-feature-instance/hooks/use-list-quer
39
40
  import { useRouter } from "next/navigation";
40
41
  import { useCallback, useEffect, useMemo, useState } from "react";
41
42
  import { BUNDLE_ID } from "../constants/bundle-common.js";
42
- import { FORM_LEAD_ROUTING_OPTIONS, FORM_LIST_FILTER_FIELDS, FORM_LIST_FILTER_HEADERS, FORM_LIST_QUERY, FORM_LIST_SELECT_FIELDS, FORM_STATUS_OPTIONS, formListRequest } from "./form-list-query.js";
43
+ import { FORM_IN_CAMPAIGN_OPTIONS, FORM_LEAD_ROUTING_OPTIONS, FORM_LIST_FILTER_FIELDS, FORM_LIST_FILTER_HEADERS, FORM_LIST_QUERY, FORM_LIST_SELECT_FIELDS, FORM_STATUS_OPTIONS, formListRequest } from "./form-list-query.js";
43
44
  /**
44
45
  * The filterable columns the table shows. The rest of the declaration —
45
- * Status, Lead routing, Campaign — reaches the Filters panel as hidden
46
+ * Status, Lead routing, Campaign, In a campaign — reaches the Filters panel as hidden
46
47
  * columns (`hiddenFilterColumns`).
47
48
  */ const FORM_VISIBLE_FILTER_COLUMNS = [
48
49
  'displayName',
@@ -75,11 +76,13 @@ const FORM_HIDDEN_COLUMNS = hiddenFilterVisibility(FORM_LIST_FILTER_FIELDS, FORM
75
76
  * ## The two numeric columns, and when one is honestly blank
76
77
  *
77
78
  * `stats.submissions` and `stats.leads` are both incremented by
78
- * `/api/forms/submit` on a write that was happening anyway, so both are real
79
- * numbers. `leads` is counted only for a form whose `routing.lead` is set, so
80
- * a form that does not route to leads carries no figure at all and renders as
81
- * a dash rather than as `0`. A zero would say this form has produced no leads,
82
- * which for an unrouted form is a measurement nobody took.
79
+ * `/api/forms/submit` on a write that was happening anyway, and recounted
80
+ * from their rows after a removal (AGL-3330), so both are real numbers.
81
+ * `leads` is counted only for a form whose `routing.lead` is set — it holds
82
+ * `0` there until the first lead — so a form that has never routed to leads
83
+ * carries no figure at all and renders as a dash rather than as `0`. A zero
84
+ * would say this form has produced no leads, which for an unrouted form is a
85
+ * measurement nobody took.
83
86
  */ export function HostFormsCard(props) {
84
87
  const { hostId, basePath, org } = props;
85
88
  const router = useRouter();
@@ -138,7 +141,11 @@ const FORM_HIDDEN_COLUMNS = hiddenFilterVisibility(FORM_LIST_FILTER_FIELDS, FORM
138
141
  await updateDoc(doc(firestore, 'hosts', hostId, 'forms', formId), {
139
142
  archivedAt: retired ? Date.now() : null,
140
143
  // The mirror the list's query asks (AGL-3330).
141
- retired
144
+ retired,
145
+ // Retiring and restoring are edits, and Updated says when the form
146
+ // was last edited (AGL-3330): a write that skipped it left a form
147
+ // retired today reading as untouched for weeks.
148
+ updatedAt: Timestamp.now()
142
149
  });
143
150
  setRetiredEpoch((epoch)=>epoch + 1);
144
151
  } catch (unused) {
@@ -218,7 +225,8 @@ const FORM_HIDDEN_COLUMNS = hiddenFilterVisibility(FORM_LIST_FILTER_FIELDS, FORM
218
225
  campaignIds: [
219
226
  ...named,
220
227
  ...unnamed
221
- ]
228
+ ],
229
+ inCampaign: FORM_IN_CAMPAIGN_OPTIONS
222
230
  };
223
231
  }, [
224
232
  siteCampaigns.options,
@@ -356,12 +364,13 @@ const FORM_HIDDEN_COLUMNS = hiddenFilterVisibility(FORM_LIST_FILTER_FIELDS, FORM
356
364
  align: 'right',
357
365
  headerAlign: 'right',
358
366
  /*
359
- * A dash rather than a `0` whenever the field is absent.
367
+ * A dash rather than a `0` whenever the field holds no number.
360
368
  *
361
- * `stats.leads` is incremented only for a form whose `routing.lead` is
362
- * set, so a form that does not route to leads has no recorded figure.
363
- * Rendering `0` there would read as "this form has produced no leads",
364
- * which is a claim about a measurement nobody took.
369
+ * `stats.leads` is counted only for a form whose `routing.lead` is set
370
+ * (`0` there until its first lead), so a form that has never routed to
371
+ * leads has no recorded figure. Rendering `0` there would read as "this
372
+ * form has produced no leads", which is a claim about a measurement
373
+ * nobody took.
365
374
  */ valueGetter: (_value, row)=>{
366
375
  var _row_stats;
367
376
  return typeof (row == null ? void 0 : (_row_stats = row.stats) == null ? void 0 : _row_stats.leads) === 'number' ? row.stats.leads : null;
@@ -422,6 +431,16 @@ const FORM_HIDDEN_COLUMNS = hiddenFilterVisibility(FORM_LIST_FILTER_FIELDS, FORM
422
431
  return String((row == null ? void 0 : (_row_routing = row.routing) == null ? void 0 : _row_routing.lead) === true);
423
432
  }
424
433
  },
434
+ /*
435
+ * In a campaign, hidden like the two above. Reads the ids the form is
436
+ * filed under — the fact `inCampaign` mirrors for the query — so a form
437
+ * the backfill has not reached still draws the truth.
438
+ */ {
439
+ field: 'inCampaign',
440
+ headerName: 'In a campaign',
441
+ minWidth: 130,
442
+ valueGetter: (_value, row)=>String(Aglyn.readCampaignIds(row).length > 0)
443
+ },
425
444
  listActionsColumn((row)=>{
426
445
  var _form_displayName;
427
446
  const form = _extends({}, row, {
@@ -634,7 +653,10 @@ const FORM_HIDDEN_COLUMNS = hiddenFilterVisibility(FORM_LIST_FILTER_FIELDS, FORM
634
653
  // rather than "these are on their way".
635
654
  loading: status === 'loading',
636
655
  // Paged by the footer below, so the grid must not also slice.
637
- hideFooter: true
656
+ hideFooter: true,
657
+ // The rows keep the query's order; a header sort would order only
658
+ // the page on screen and read as the whole list's.
659
+ disableColumnSorting: true
638
660
  })),
639
661
  /*#__PURE__*/ _jsx(ListPagination, {
640
662
  page: page,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/forms/src/lib/components/host-forms-card.component.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 * as Aglyn from '@aglyn/aglyn'\nimport type { AglynOrgBilling } from '@aglyn/aglyn'\nimport {\n buildRoute,\n isFormArchived,\n PageHeaderActions,\n pluginDocsHelp,\n Route,\n} from '@aglyn/aglyn'\nimport { useConsoleWidgetSlot } from '@aglyn/aglyn/app-utils/console-widget-slot-context'\nimport { ICON_VARIANT_SHOW_DETAIL } from '@aglyn/shared-data-enums'\nimport {\n mdiArchiveArrowDownOutline,\n mdiArchiveArrowUpOutline,\n mdiContentCopy,\n mdiEyeOutline,\n mdiVectorSquare,\n} from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon } 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 ListTable, {\n ListRowActions,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { hiddenFilterVisibility } from '@aglyn/shared-ui-jsx/const/list-filter'\nimport {\n type ListFilterClause,\n type ListFilterOption,\n listFilterGridColumns,\n} 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 QuotaReadoutComponent from '@aglyn/shared-ui-jsx/components/quota-readout.component'\nimport { CreateArtifactDrawer } from '@aglyn/shared-ui-jsx-forms'\nimport { Alert, Button, Stack, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n doc,\n getCountFromServer,\n query,\n updateDoc,\n where,\n} from 'firebase/firestore'\nimport {\n DUPLICATE_MENU_LABEL,\n useConsoleHostRoute,\n useDuplicateResource,\n useFirestore,\n useHostCampaigns,\n useHostResourceApi,\n useLiveArtifactCount,\n} from '@aglyn/tenant-feature-instance'\nimport { useListQuery } from '@aglyn/tenant-feature-instance/hooks/use-list-query'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport {\n FORM_LEAD_ROUTING_OPTIONS,\n FORM_LIST_FILTER_FIELDS,\n FORM_LIST_FILTER_HEADERS,\n FORM_LIST_QUERY,\n FORM_LIST_SELECT_FIELDS,\n FORM_STATUS_OPTIONS,\n formListRequest,\n} from './form-list-query'\n\n/**\n * The filterable columns the table shows. The rest of the declaration —\n * Status, Lead routing, Campaign — reaches the Filters panel as hidden\n * columns (`hiddenFilterColumns`).\n */\nconst FORM_VISIBLE_FILTER_COLUMNS = [\n 'displayName',\n 'slug',\n 'submissions',\n 'leads',\n 'lastSubmission',\n 'updatedAt',\n]\nconst FORM_HIDDEN_COLUMNS = hiddenFilterVisibility(\n FORM_LIST_FILTER_FIELDS,\n FORM_VISIBLE_FILTER_COLUMNS,\n)\n\nexport interface HostFormsCardProps {\n hostId: string\n /**\n * The Forms surface's own absolute console path, from the shell.\n *\n * A row's link is this plus the form's id, resolved synchronously. The\n * alternative is `useConsoleHostRoute`, which answers `null` for a paint\n * while it reads two documents — and a table whose every row links to\n * `/null/...` on first render is worse than one that pays nothing.\n */\n basePath?: string\n /**\n * The org billing document, from the shell.\n *\n * The quota readout's denominator resolves from it. `undefined` means the\n * plan is not known rather than that it is Free — the shell holds this\n * surface behind a spinner until the org read settles, so it reads that way\n * only when the read failed — and the readout says so instead of naming a\n * cap it cannot support.\n */\n org?: Partial<AglynOrgBilling>\n}\n\n/**\n * THE FORM CATALOG, WITH ITS SHAPE VISIBLE BEFORE THERE IS ANYTHING IN IT.\n *\n * The list is the components list's table, deliberately and not\n * approximately: `ListTable` gives it the row grammar every artifact list has\n * — the row opens the detail page, rows are not selectable, one quick action\n * then the overflow — and `ListPagination` gives it the console's one footer.\n * A form is an artifact like a component, so a reader who has used one list\n * has used this one.\n *\n * ## Why the empty state is a table and not a sentence\n *\n * A paragraph and a button reads as a smaller feature than this is: a form is\n * a thing with a slug, a submission count and a version history, and none of\n * that would be visible until the reader had already committed to making one.\n * Rendering the columns with the empty overlay inside them teaches the shape\n * of the artifact BEFORE the first one exists, which is what the components\n * list has always done.\n *\n * ## The two numeric columns, and when one is honestly blank\n *\n * `stats.submissions` and `stats.leads` are both incremented by\n * `/api/forms/submit` on a write that was happening anyway, so both are real\n * numbers. `leads` is counted only for a form whose `routing.lead` is set, so\n * a form that does not route to leads carries no figure at all and renders as\n * a dash rather than as `0`. A zero would say this form has produced no leads,\n * which for an unrouted form is a measurement nobody took.\n */\nexport function HostFormsCard(props: HostFormsCardProps) {\n const { hostId, basePath, org } = props\n const router = useRouter()\n // Duplicate (AGL-2936): the copy is a form of its own — its submissions\n // start empty — and opens on its detail page once made.\n const duplicate = useDuplicateResource({\n hostId,\n onDuplicated: (_kind, copy) => router.push(formHref(copy.id)),\n })\n const { orgSlug, subdomain: host } = useConsoleHostRoute(hostId)\n const firestore = useFirestore()\n const createHostResource = useHostResourceApi()\n /**\n * The shell's zone renderer (AGL-3043), for other ways to start a form; `null`\n * outside the console shell, where there is no workspace to gate on.\n */\n const CreateZone = useConsoleWidgetSlot()\n\n /**\n * One form's page, beneath this surface's own path.\n *\n * `basePath` is the shell's answer and needs no read; the route table is the\n * fallback for a caller that has none, and it is the one that can be `null`\n * for a paint.\n */\n const formHref = useCallback(\n (formId: string) =>\n basePath\n ? `${basePath}/${formId}`\n : buildRoute(Route.FORM_DETAILS, {\n orgSlug: orgSlug ?? '',\n host: host ?? '',\n formId,\n }),\n [basePath, orgSlug, host],\n )\n\n const [createOpen, setCreateOpen] = useState(false)\n const [createError, setCreateError] = useState<string | null>(null)\n const [creating, setCreating] = useState(false)\n const [retireError, setRetireError] = useState<string | null>(null)\n /** Bumped by each retire or restore, so the retired count is asked again. */\n const [retiredEpoch, setRetiredEpoch] = useState(0)\n\n /**\n * Retire a form, or put it back (AGL-2671).\n *\n * A write of one field, client-side like every other edit on this document:\n * the rules already admit it for a host writer, fencing off only `stats`.\n *\n * ⛔ NOT a delete, and the difference is the whole feature. The submissions,\n * the leads and the contact timeline they built are filed under this form,\n * so the document is what makes that history readable; deleting it to stop\n * collection would throw away the reason the merchant kept the form.\n *\n * `Date.now()` is the browser's clock rather than `serverTimestamp()`\n * because {@link isFormArchived} asks for a number greater than zero, and a\n * server sentinel reads as `null` on the local echo — the row would stay\n * looking live until the write round-tripped. The value is a marker, never\n * an audit fact: nothing computes a duration from it.\n */\n const setRetired = useCallback(\n async (formId: string, retired: boolean) => {\n setRetireError(null)\n try {\n await updateDoc(doc(firestore, 'hosts', hostId, 'forms', formId), {\n archivedAt: retired ? Date.now() : null,\n // The mirror the list's query asks (AGL-3330).\n retired,\n })\n setRetiredEpoch((epoch) => epoch + 1)\n } catch {\n setRetireError(\n retired\n ? 'Could not retire that form. Try again shortly.'\n : 'Could not restore that form. Try again shortly.',\n )\n }\n },\n [firestore, hostId],\n )\n\n /*\n * THE LIST IS ITS QUERY (AGL-3330, on the AGL-3321 list query plan).\n *\n * The grid's Filters panel and search box edit the clauses and the words;\n * `useListQuery` puts every one of them on ONE Firestore query and pages\n * that query, so a page is a page of matches and a form whose only match\n * is past the first page is still found. What one query cannot hold — a\n * second range, a second array clause — is refused by name above the list\n * (`ListQueryNotices`) rather than matched over the rows read. See\n * `form-list-query.ts` for every field and the indexes they cost.\n *\n * Unfiltered it reads in document order, as it always has: `orderBy` on a\n * name would drop every form stored without one rather than mis-sort it.\n *\n * A retired form is a TOMBSTONE, not a row, unless the reader asks for\n * Status (AGL-2671). The query leaves them out (`retired == false`), so a\n * page is a page of forms in use; a Status clause replaces that with the\n * reader's own choice. Retiring a form removes it from the list, and\n * without the Status filter that would be a one-way door: the row is the\n * only route back to Restore.\n */\n const [formClauses, setFormClauses] = useState<ListFilterClause[]>([])\n const gridFilter = useListGridFilter({\n clauses: formClauses,\n onChange: setFormClauses,\n selectFields: FORM_LIST_SELECT_FIELDS,\n })\n const filtering = gridFilter.clauses.length > 0 || gridFilter.searchWords.length > 0\n const formsCollection = useMemo(\n () => collection(firestore, 'hosts', hostId, 'forms'),\n [firestore, hostId],\n )\n const {\n status,\n rows: forms,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n plan,\n } = useListQuery<any>({\n collection: formsCollection,\n declaration: FORM_LIST_QUERY,\n request: formListRequest(gridFilter.clauses, gridFilter.searchWords),\n deps: [firestore, hostId],\n idField: '$id',\n })\n\n /*\n * The Campaign filter's choices: the campaigns placed on this site, by\n * name, and any campaign a clause names that the list does not, by its id,\n * so the chip can still be read and cleared.\n *\n * Read once the reader opens the Filters panel, not on every visit: the\n * names cost the org lookup and up to fifty campaign documents, which a\n * reader who came to open one form would pay for a filter they never\n * touched. Until they land the column has no choices, and the panel lists\n * it a moment later.\n */\n const [wantsCampaigns, setWantsCampaigns] = useState(false)\n const siteCampaigns = useHostCampaigns(hostId, {\n enabled: wantsCampaigns || formClauses.some((clause) => clause.field === 'campaignIds'),\n })\n const formFilterOptions = useMemo<Record<string, ListFilterOption[]>>(() => {\n const named = siteCampaigns.options.map((option) => ({\n value: option.value,\n label: option.label,\n }))\n const unnamed = formClauses\n .filter((clause) => clause.field === 'campaignIds')\n .flatMap((clause) => clause.value.split(','))\n .map((value) => value.trim())\n .filter((value) => value && !named.some((option) => option.value === value))\n .map((value) => ({ value, label: value }))\n return {\n status: FORM_STATUS_OPTIONS,\n leadRouting: FORM_LEAD_ROUTING_OPTIONS,\n campaignIds: [...named, ...unnamed],\n }\n }, [siteCampaigns.options, formClauses])\n\n /*\n * The COUNT is a server aggregate, not the length of a page. `forms` is one\n * page, and publishing that as the site's form count would read as room to\n * spare on a site that is already at the ceiling.\n */\n const liveFormCount = useLiveArtifactCount(hostId, 'forms')\n // Pending or refused, the page stands in: a LOWER bound, never a confident\n // zero.\n const formsUsed = liveFormCount ?? forms.length\n /*\n * How many forms are retired, for the \"keep their slot\" line (AGL-2674).\n * An aggregate, because the list no longer reads a retired row unless it is\n * asked for one; asked again after this card retires or restores a form.\n */\n const [retiredCount, setRetiredCount] = useState<number | null>(null)\n useEffect(() => {\n let active = true\n getCountFromServer(query(formsCollection, where('retired', '==', true)))\n .then((snapshot) => {\n if (active) setRetiredCount(snapshot.data().count)\n })\n .catch(() => {\n // The line is a courtesy; without the count it is simply not shown.\n })\n return () => {\n active = false\n }\n }, [formsCollection, retiredEpoch])\n\n /**\n * Name first, then create (AGL-700).\n *\n * A form is created with BOTH halves seeded. `fields` is the declaration the\n * submission path reads and starts empty; the canvas is the design, seeded\n * with a root and a form node already bound to this id — so the besigner\n * opens on something that satisfies `checkFormContract` rather than on a\n * blank page whose first publish is a list of violations.\n */\n const handleCreate = useCallback(\n async (values: Record<string, any>) => {\n if (creating) return\n setCreating(true)\n setCreateError(null)\n try {\n const formId = Aglyn.createResourceUid()\n await createHostResource({\n hostId,\n resource: 'form',\n id: formId,\n data: {\n displayName: values['displayName'],\n slug: Aglyn.normalizeFormSlug(values['displayName']) || formId,\n fields: [],\n rootId: Aglyn.CANVAS_ROOT_ELEMENT_ID,\n nodes: {\n [Aglyn.CANVAS_ROOT_ELEMENT_ID]: {\n $id: Aglyn.CANVAS_ROOT_ELEMENT_ID,\n componentId: 'div',\n nodes: ['formRoot'],\n },\n formRoot: {\n $id: 'formRoot',\n componentId: 'form',\n pluginId: BUNDLE_ID,\n parentId: Aglyn.CANVAS_ROOT_ELEMENT_ID,\n props: { formId, formName: values['displayName'] },\n nodes: [],\n },\n },\n },\n })\n setCreateOpen(false)\n router.push(formHref(formId))\n } catch (error) {\n console.error(error)\n setCreateError('Could not create that form')\n } finally {\n setCreating(false)\n }\n },\n [creating, createHostResource, hostId, router, formHref],\n )\n\n const columns: GridColDef[] = [\n {\n field: 'displayName',\n headerName: 'Display name',\n minWidth: 220,\n type: 'string',\n renderCell: ({ id, value }: any) => (\n <AppLink href={formHref(id as string)}>\n {value || (id as string)}\n </AppLink>\n ),\n },\n {\n field: 'slug',\n headerName: 'Slug',\n minWidth: 160,\n flex: 1,\n type: 'string',\n // Blank reads as a rendering gap; '--' reads as \"nothing here\".\n valueFormatter: (value: any) => value || '--',\n },\n {\n field: 'submissions',\n headerName: 'Submissions',\n minWidth: 130,\n type: 'number',\n // Head AND body. `type: 'number'` right-aligns both, and the explicit\n // pair says so at the call site rather than relying on a grid default\n // that a later `renderCell` would silently override.\n align: 'right',\n headerAlign: 'right',\n valueGetter: (_value: any, row: any) => row?.stats?.submissions ?? null,\n valueFormatter: (value: any) =>\n typeof value === 'number' ? value.toLocaleString() : '--',\n },\n {\n field: 'leads',\n headerName: 'Leads',\n minWidth: 100,\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n /*\n * A dash rather than a `0` whenever the field is absent.\n *\n * `stats.leads` is incremented only for a form whose `routing.lead` is\n * set, so a form that does not route to leads has no recorded figure.\n * Rendering `0` there would read as \"this form has produced no leads\",\n * which is a claim about a measurement nobody took.\n */\n valueGetter: (_value: any, row: any) =>\n typeof row?.stats?.leads === 'number' ? row.stats.leads : null,\n valueFormatter: (value: any) =>\n typeof value === 'number' ? value.toLocaleString() : '--',\n },\n {\n field: 'lastSubmission',\n headerName: 'Last submission',\n minWidth: 170,\n flex: 1,\n type: 'date',\n valueGetter: (_value: any, row: any) =>\n typeof row?.stats?.lastSubmissionAtMs === 'number'\n ? new Date(row.stats.lastSubmissionAtMs)\n : null,\n valueFormatter: (value: any) => value?.toLocaleString?.() || '--',\n },\n {\n field: 'updatedAt',\n headerName: 'Updated',\n minWidth: 170,\n flex: 1,\n type: 'date',\n // MUI X v9 passes the value positionally. The v6 object form silently\n // destructures undefined off a Date and every row renders '--'.\n valueGetter: (value: any) => value?.toDate?.() ?? null,\n valueFormatter: (value: any) => value?.toLocaleString?.() || '--',\n },\n /*\n * Status and Lead routing, hidden until Columns shows them (AGL-3330).\n * Each is a select the Filters panel offers either way, and draws the\n * choice its value names. Status reads the FACT, `archivedAt`, rather\n * than the `retired` mirror the query asks, so a form the backfill has\n * not reached yet still reads true.\n */\n {\n field: 'status',\n headerName: 'Status',\n minWidth: 110,\n valueGetter: (_value: any, row: any) => String(isFormArchived(row)),\n },\n {\n field: 'leadRouting',\n headerName: 'Lead routing',\n minWidth: 120,\n valueGetter: (_value: any, row: any) => String(row?.routing?.lead === true),\n },\n listActionsColumn((row: any) => {\n const form = { ...row, $id: row.$id as string }\n const versionId = form.versionId as string | undefined\n return (\n <ListRowActions\n label={form.displayName ?? form.$id}\n quick={{\n icon: mdiEyeOutline.path,\n label: 'Preview',\n // A form with no version has never been opened in the besigner, so\n // there is no snapshot to render. Disabled and saying so, rather\n // than a link to an empty preview.\n ...(versionId && orgSlug && host\n ? {\n to: buildRoute(Route.FORM_PREVIEW, {\n orgSlug,\n host,\n formId: form.$id,\n versionId,\n }),\n }\n : {\n unavailableReason: versionId\n ? 'Resolving this site’s address…'\n : 'Nothing to preview yet — open it in the besigner once.',\n }),\n }}\n items={[\n {\n key: 'details',\n label: 'View details',\n icon: <MdiIcon path={ICON_VARIANT_SHOW_DETAIL.path} size={0.8} />,\n href: formHref(form.$id),\n },\n {\n key: 'besigner',\n label: 'Edit in besigner',\n icon: <MdiIcon path={mdiVectorSquare.path} size={0.8} />,\n /*\n * A LINK only once the form has a version. A form that has never\n * been opened has none, and opening it MINTS the first one —\n * that is a write, and the detail page is the one place that\n * decides what an initial version looks like. Sending the reader\n * there rather than minting a second way is what keeps the two\n * from drifting.\n */\n href:\n versionId && orgSlug && host\n ? buildRoute(Route.FORM_BESIGNER, {\n orgSlug,\n host,\n formId: form.$id,\n versionId,\n })\n : formHref(form.$id),\n },\n /*\n * Retire / Restore (AGL-2671). Last in the menu, and the only item\n * here that writes.\n *\n * The label says what it DOES to the form rather than what it does\n * to this list — \"Archive\" reads like a filing action, and the\n * consequence a merchant needs to weigh is that the form stops\n * collecting. `/api/forms/submit` refuses a retired form, so one\n * still placed on a published page will turn visitors away.\n */\n {\n key: 'duplicate',\n label: DUPLICATE_MENU_LABEL,\n icon: <MdiIcon path={mdiContentCopy.path} size={0.8} />,\n onClick: () =>\n duplicate.request('form', {\n id: form.$id,\n name: form.displayName ?? '',\n }),\n },\n isFormArchived(form)\n ? {\n key: 'restore',\n label: 'Restore — start collecting again',\n icon: <MdiIcon path={mdiArchiveArrowUpOutline.path} size={0.8} />,\n onClick: () => void setRetired(form.$id, false),\n }\n : {\n key: 'retire',\n label: 'Retire — stop collecting, keep the history',\n icon: <MdiIcon path={mdiArchiveArrowDownOutline.path} size={0.8} />,\n onClick: () => void setRetired(form.$id, true),\n },\n ]}\n />\n )\n }),\n ]\n\n return (\n <>\n {duplicate.dialog}\n {/*\n The readout leads the create button, in the PAGE header — where Sites,\n screens, layouts, components and templates put theirs. Forms declares\n no sections, so it has no vertical rail for a card-header cluster to\n belong beside, and the controls are about the whole surface rather\n than about anything the card is showing.\n Published from the card because the card owns what they say: the count\n comes from the listener already open here, and a page that counted for\n itself would be a second source for one fact and a second read for one\n collection. It publishes from the LIST, so a form's own detail route —\n a different component — leaves the header with nothing to create into.\n */}\n <PageHeaderActions>\n <Stack direction=\"row\" spacing={2} sx={{ alignItems: 'center' }}>\n {/*\n The denominator is `formsPerHost` — the allowance\n `/api/hosts/resources` refuses the create at, resolved through the\n same `checkQuota` the route calls, so the number offered and the\n number enforced cannot drift apart. `FORMS_MAX_PER_HOST` bounds a\n READ of the catalog and sits above every plan's allowance, so\n quoting it advertises room the server will not honor.\n */}\n <QuotaReadoutComponent\n ready={org != null}\n used={formsUsed}\n limit={Aglyn.checkQuota(org, 'formsPerHost', formsUsed).limit}\n noun=\"form\"\n />\n {/*\n A retired form keeps its slot, and the readout says so rather than\n leaving a merchant to work out why a catalog of three is using five\n (AGL-2674). The count comes from the same aggregate\n `/api/hosts/resources` enforces on, which subtracts deleted forms\n and nothing else — so retirement is deliberately not a way to buy\n room back, and the number here is the number the server will\n refuse a create at.\n\n Shown only when the site has retired a form (an aggregate — the\n list reads no retired row unless asked): the line answers a\n question nobody is asking on a site that has never retired\n anything.\n */}\n {retiredCount ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Retired forms keep their slot'}\n </Typography>\n ) : null}\n <Stack direction=\"row\" spacing={1}>\n {/*\n Other ways to start a form, from plugins (AGL-3043): the\n `hostForms` zone, drawn through the shell's own gated slot. A\n plugin page cannot mount that slot itself, so the shell hands it\n down, and a widget here passes the gates a console page's would.\n */}\n {CreateZone ? (\n <CreateZone slot=\"hostForms\" hostId={hostId} orgId={org?.$id} />\n ) : null}\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={creating}\n onClick={() => {\n setCreateError(null)\n setCreateOpen(true)\n }}\n >\n {creating ? 'Creating…' : 'Create Form'}\n </Button>\n </Stack>\n </Stack>\n </PageHeaderActions>\n <CardDisplay\n header=\"Forms\"\n help={pluginDocsHelp('forms', {\n anchor: '#find-a-form',\n excerpt:\n 'A form collects submissions into the Inbox, and its design is ' +\n 'drawn in the besigner like any other artifact.',\n })}\n >\n {retireError ? (\n <Alert severity=\"error\" sx={{ mb: 2 }}>\n {retireError}\n </Alert>\n ) : null}\n <ListFilterChips\n fields={FORM_LIST_FILTER_FIELDS}\n headers={FORM_LIST_FILTER_HEADERS}\n clauses={gridFilter.clauses}\n onChange={gridFilter.setClauses}\n options={formFilterOptions}\n />\n <ListQueryNotices\n refused={listQueryRefusals(plan.refused, {\n fields: FORM_LIST_FILTER_FIELDS,\n headers: FORM_LIST_FILTER_HEADERS,\n options: formFilterOptions,\n })}\n notices={plan.notices}\n />\n <ListTable\n rowHeight={TABLE_ROW_HEIGHT}\n columns={listFilterGridColumns(\n columns,\n FORM_LIST_FILTER_FIELDS,\n formFilterOptions,\n FORM_LIST_FILTER_HEADERS,\n )}\n initialState={{ columns: { columnVisibilityModel: FORM_HIDDEN_COLUMNS } }}\n /*\n * The grid must NOT also filter. The query answers the panel and\n * the search, so a second pass in the browser could only drop rows\n * that already matched — it compares what a column DRAWS, and the\n * query compares what the document stores.\n */\n filterMode=\"server\"\n filterModel={gridFilter.filterModel}\n onFilterModelChange={gridFilter.onFilterModelChange}\n quickFilter\n // The Campaign filter's names are read once the panel is opened.\n onPreferencePanelOpen={(params) => {\n if (params.openedPanelValue === 'filters') setWantsCampaigns(true)\n }}\n {...(filtering\n ? { noRowsLabel: 'No forms match these filters' }\n : {\n noRowsLabel: 'No forms yet',\n noRowsDescription:\n 'A form collects submissions, dedupes the people who send them, and can route them to a lead. Its design is drawn in the besigner and published like any other artifact.',\n noRowsAction: (\n <Button variant=\"contained\" onClick={() => setCreateOpen(true)}>\n {'Create your first form'}\n </Button>\n ),\n })}\n rows={forms}\n // The whole row opens the detail page; the action cluster stops\n // propagation so a menu click never navigates underneath it.\n onOpen={(id) => router.push(formHref(String(id)))}\n // An empty table while the read is in flight reads as \"you have none\"\n // rather than \"these are on their way\".\n loading={status === 'loading'}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n />\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={forms.length}\n hasMore={hasMore}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n {/*\n The console's own create drawer, from the shared library rather than a\n second one that looks like it. The empty state and the header open the\n SAME one, so the state that says \"creating…\" is one state.\n */}\n <CreateArtifactDrawer\n open={createOpen}\n onClose={() => setCreateOpen(false)}\n title=\"Create new form\"\n // A form document stores no description: `/api/hosts/resources` filters\n // `data` through a per-kind allow-list, so one typed here is dropped\n // without a word.\n includeDescription={false}\n onSubmit={handleCreate}\n errorSlot={\n createError ? (\n <Alert severity=\"error\" sx={{ mt: 2, mb: 1 }}>\n {createError}\n </Alert>\n ) : null\n }\n />\n </CardDisplay>\n </>\n )\n}\nHostFormsCard.displayName = 'HostFormsCard'\n\nexport default HostFormsCard\n"],"names":["Aglyn","buildRoute","isFormArchived","PageHeaderActions","pluginDocsHelp","Route","useConsoleWidgetSlot","ICON_VARIANT_SHOW_DETAIL","mdiArchiveArrowDownOutline","mdiArchiveArrowUpOutline","mdiContentCopy","mdiEyeOutline","mdiVectorSquare","AppLink","CardDisplay","MdiIcon","ListFilterChips","ListPagination","ListQueryNotices","listQueryRefusals","ListTable","ListRowActions","listActionsColumn","hiddenFilterVisibility","listFilterGridColumns","TABLE_ROW_HEIGHT","useListGridFilter","QuotaReadoutComponent","CreateArtifactDrawer","Alert","Button","Stack","Typography","collection","doc","getCountFromServer","query","updateDoc","where","DUPLICATE_MENU_LABEL","useConsoleHostRoute","useDuplicateResource","useFirestore","useHostCampaigns","useHostResourceApi","useLiveArtifactCount","useListQuery","useRouter","useCallback","useEffect","useMemo","useState","BUNDLE_ID","FORM_LEAD_ROUTING_OPTIONS","FORM_LIST_FILTER_FIELDS","FORM_LIST_FILTER_HEADERS","FORM_LIST_QUERY","FORM_LIST_SELECT_FIELDS","FORM_STATUS_OPTIONS","formListRequest","FORM_VISIBLE_FILTER_COLUMNS","FORM_HIDDEN_COLUMNS","HostFormsCard","props","hostId","basePath","org","router","duplicate","onDuplicated","_kind","copy","push","formHref","id","orgSlug","subdomain","host","firestore","createHostResource","CreateZone","formId","FORM_DETAILS","createOpen","setCreateOpen","createError","setCreateError","creating","setCreating","retireError","setRetireError","retiredEpoch","setRetiredEpoch","setRetired","retired","archivedAt","Date","now","epoch","formClauses","setFormClauses","gridFilter","clauses","onChange","selectFields","filtering","length","searchWords","formsCollection","status","rows","forms","hasMore","page","setPage","pageSize","setPageSize","plan","declaration","request","deps","idField","wantsCampaigns","setWantsCampaigns","siteCampaigns","enabled","some","clause","field","formFilterOptions","named","options","map","option","value","label","unnamed","filter","flatMap","split","trim","leadRouting","campaignIds","liveFormCount","formsUsed","retiredCount","setRetiredCount","active","then","snapshot","data","count","catch","handleCreate","values","createResourceUid","resource","displayName","slug","normalizeFormSlug","fields","rootId","CANVAS_ROOT_ELEMENT_ID","nodes","$id","componentId","formRoot","pluginId","parentId","formName","error","console","columns","headerName","minWidth","type","renderCell","href","flex","valueFormatter","align","headerAlign","valueGetter","_value","row","stats","submissions","toLocaleString","leads","lastSubmissionAtMs","toDate","String","routing","lead","form","versionId","quick","icon","path","to","FORM_PREVIEW","unavailableReason","items","key","size","FORM_BESIGNER","onClick","name","dialog","direction","spacing","sx","alignItems","ready","used","limit","checkQuota","noun","variant","color","slot","orgId","disabled","header","help","anchor","excerpt","severity","mb","headers","setClauses","refused","notices","rowHeight","initialState","columnVisibilityModel","filterMode","filterModel","onFilterModelChange","quickFilter","onPreferencePanelOpen","params","openedPanelValue","noRowsLabel","noRowsDescription","noRowsAction","onOpen","loading","hideFooter","rowCount","onPageChange","onPageSizeChange","open","onClose","title","includeDescription","onSubmit","errorSlot","mt"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/forms/src/lib/components/host-forms-card.component.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 * as Aglyn from '@aglyn/aglyn'\nimport type { AglynOrgBilling } from '@aglyn/aglyn'\nimport {\n buildRoute,\n isFormArchived,\n PageHeaderActions,\n pluginDocsHelp,\n Route,\n} from '@aglyn/aglyn'\nimport { useConsoleWidgetSlot } from '@aglyn/aglyn/app-utils/console-widget-slot-context'\nimport { ICON_VARIANT_SHOW_DETAIL } from '@aglyn/shared-data-enums'\nimport {\n mdiArchiveArrowDownOutline,\n mdiArchiveArrowUpOutline,\n mdiContentCopy,\n mdiEyeOutline,\n mdiVectorSquare,\n} from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon } 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 ListTable, {\n ListRowActions,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { hiddenFilterVisibility } from '@aglyn/shared-ui-jsx/const/list-filter'\nimport {\n type ListFilterClause,\n type ListFilterOption,\n listFilterGridColumns,\n} 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 QuotaReadoutComponent from '@aglyn/shared-ui-jsx/components/quota-readout.component'\nimport { CreateArtifactDrawer } from '@aglyn/shared-ui-jsx-forms'\nimport { Timestamp } from '@aglyn/shared-util-timestamp'\nimport { Alert, Button, Stack, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n collection,\n doc,\n getCountFromServer,\n query,\n updateDoc,\n where,\n} from 'firebase/firestore'\nimport {\n DUPLICATE_MENU_LABEL,\n useConsoleHostRoute,\n useDuplicateResource,\n useFirestore,\n useHostCampaigns,\n useHostResourceApi,\n useLiveArtifactCount,\n} from '@aglyn/tenant-feature-instance'\nimport { useListQuery } from '@aglyn/tenant-feature-instance/hooks/use-list-query'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport {\n FORM_IN_CAMPAIGN_OPTIONS,\n FORM_LEAD_ROUTING_OPTIONS,\n FORM_LIST_FILTER_FIELDS,\n FORM_LIST_FILTER_HEADERS,\n FORM_LIST_QUERY,\n FORM_LIST_SELECT_FIELDS,\n FORM_STATUS_OPTIONS,\n formListRequest,\n} from './form-list-query'\n\n/**\n * The filterable columns the table shows. The rest of the declaration —\n * Status, Lead routing, Campaign, In a campaign — reaches the Filters panel as hidden\n * columns (`hiddenFilterColumns`).\n */\nconst FORM_VISIBLE_FILTER_COLUMNS = [\n 'displayName',\n 'slug',\n 'submissions',\n 'leads',\n 'lastSubmission',\n 'updatedAt',\n]\nconst FORM_HIDDEN_COLUMNS = hiddenFilterVisibility(\n FORM_LIST_FILTER_FIELDS,\n FORM_VISIBLE_FILTER_COLUMNS,\n)\n\nexport interface HostFormsCardProps {\n hostId: string\n /**\n * The Forms surface's own absolute console path, from the shell.\n *\n * A row's link is this plus the form's id, resolved synchronously. The\n * alternative is `useConsoleHostRoute`, which answers `null` for a paint\n * while it reads two documents — and a table whose every row links to\n * `/null/...` on first render is worse than one that pays nothing.\n */\n basePath?: string\n /**\n * The org billing document, from the shell.\n *\n * The quota readout's denominator resolves from it. `undefined` means the\n * plan is not known rather than that it is Free — the shell holds this\n * surface behind a spinner until the org read settles, so it reads that way\n * only when the read failed — and the readout says so instead of naming a\n * cap it cannot support.\n */\n org?: Partial<AglynOrgBilling>\n}\n\n/**\n * THE FORM CATALOG, WITH ITS SHAPE VISIBLE BEFORE THERE IS ANYTHING IN IT.\n *\n * The list is the components list's table, deliberately and not\n * approximately: `ListTable` gives it the row grammar every artifact list has\n * — the row opens the detail page, rows are not selectable, one quick action\n * then the overflow — and `ListPagination` gives it the console's one footer.\n * A form is an artifact like a component, so a reader who has used one list\n * has used this one.\n *\n * ## Why the empty state is a table and not a sentence\n *\n * A paragraph and a button reads as a smaller feature than this is: a form is\n * a thing with a slug, a submission count and a version history, and none of\n * that would be visible until the reader had already committed to making one.\n * Rendering the columns with the empty overlay inside them teaches the shape\n * of the artifact BEFORE the first one exists, which is what the components\n * list has always done.\n *\n * ## The two numeric columns, and when one is honestly blank\n *\n * `stats.submissions` and `stats.leads` are both incremented by\n * `/api/forms/submit` on a write that was happening anyway, and recounted\n * from their rows after a removal (AGL-3330), so both are real numbers.\n * `leads` is counted only for a form whose `routing.lead` is set — it holds\n * `0` there until the first lead — so a form that has never routed to leads\n * carries no figure at all and renders as a dash rather than as `0`. A zero\n * would say this form has produced no leads, which for an unrouted form is a\n * measurement nobody took.\n */\nexport function HostFormsCard(props: HostFormsCardProps) {\n const { hostId, basePath, org } = props\n const router = useRouter()\n // Duplicate (AGL-2936): the copy is a form of its own — its submissions\n // start empty — and opens on its detail page once made.\n const duplicate = useDuplicateResource({\n hostId,\n onDuplicated: (_kind, copy) => router.push(formHref(copy.id)),\n })\n const { orgSlug, subdomain: host } = useConsoleHostRoute(hostId)\n const firestore = useFirestore()\n const createHostResource = useHostResourceApi()\n /**\n * The shell's zone renderer (AGL-3043), for other ways to start a form; `null`\n * outside the console shell, where there is no workspace to gate on.\n */\n const CreateZone = useConsoleWidgetSlot()\n\n /**\n * One form's page, beneath this surface's own path.\n *\n * `basePath` is the shell's answer and needs no read; the route table is the\n * fallback for a caller that has none, and it is the one that can be `null`\n * for a paint.\n */\n const formHref = useCallback(\n (formId: string) =>\n basePath\n ? `${basePath}/${formId}`\n : buildRoute(Route.FORM_DETAILS, {\n orgSlug: orgSlug ?? '',\n host: host ?? '',\n formId,\n }),\n [basePath, orgSlug, host],\n )\n\n const [createOpen, setCreateOpen] = useState(false)\n const [createError, setCreateError] = useState<string | null>(null)\n const [creating, setCreating] = useState(false)\n const [retireError, setRetireError] = useState<string | null>(null)\n /** Bumped by each retire or restore, so the retired count is asked again. */\n const [retiredEpoch, setRetiredEpoch] = useState(0)\n\n /**\n * Retire a form, or put it back (AGL-2671).\n *\n * A write of one field, client-side like every other edit on this document:\n * the rules already admit it for a host writer, fencing off only `stats`.\n *\n * ⛔ NOT a delete, and the difference is the whole feature. The submissions,\n * the leads and the contact timeline they built are filed under this form,\n * so the document is what makes that history readable; deleting it to stop\n * collection would throw away the reason the merchant kept the form.\n *\n * `Date.now()` is the browser's clock rather than `serverTimestamp()`\n * because {@link isFormArchived} asks for a number greater than zero, and a\n * server sentinel reads as `null` on the local echo — the row would stay\n * looking live until the write round-tripped. The value is a marker, never\n * an audit fact: nothing computes a duration from it.\n */\n const setRetired = useCallback(\n async (formId: string, retired: boolean) => {\n setRetireError(null)\n try {\n await updateDoc(doc(firestore, 'hosts', hostId, 'forms', formId), {\n archivedAt: retired ? Date.now() : null,\n // The mirror the list's query asks (AGL-3330).\n retired,\n // Retiring and restoring are edits, and Updated says when the form\n // was last edited (AGL-3330): a write that skipped it left a form\n // retired today reading as untouched for weeks.\n updatedAt: Timestamp.now(),\n })\n setRetiredEpoch((epoch) => epoch + 1)\n } catch {\n setRetireError(\n retired\n ? 'Could not retire that form. Try again shortly.'\n : 'Could not restore that form. Try again shortly.',\n )\n }\n },\n [firestore, hostId],\n )\n\n /*\n * THE LIST IS ITS QUERY (AGL-3330, on the AGL-3321 list query plan).\n *\n * The grid's Filters panel and search box edit the clauses and the words;\n * `useListQuery` puts every one of them on ONE Firestore query and pages\n * that query, so a page is a page of matches and a form whose only match\n * is past the first page is still found. What one query cannot hold — a\n * second range, a second array clause — is refused by name above the list\n * (`ListQueryNotices`) rather than matched over the rows read. See\n * `form-list-query.ts` for every field and the indexes they cost.\n *\n * Unfiltered it reads in document order, as it always has: `orderBy` on a\n * name would drop every form stored without one rather than mis-sort it.\n *\n * A retired form is a TOMBSTONE, not a row, unless the reader asks for\n * Status (AGL-2671). The query leaves them out (`retired == false`), so a\n * page is a page of forms in use; a Status clause replaces that with the\n * reader's own choice. Retiring a form removes it from the list, and\n * without the Status filter that would be a one-way door: the row is the\n * only route back to Restore.\n */\n const [formClauses, setFormClauses] = useState<ListFilterClause[]>([])\n const gridFilter = useListGridFilter({\n clauses: formClauses,\n onChange: setFormClauses,\n selectFields: FORM_LIST_SELECT_FIELDS,\n })\n const filtering = gridFilter.clauses.length > 0 || gridFilter.searchWords.length > 0\n const formsCollection = useMemo(\n () => collection(firestore, 'hosts', hostId, 'forms'),\n [firestore, hostId],\n )\n const {\n status,\n rows: forms,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n plan,\n } = useListQuery<any>({\n collection: formsCollection,\n declaration: FORM_LIST_QUERY,\n request: formListRequest(gridFilter.clauses, gridFilter.searchWords),\n deps: [firestore, hostId],\n idField: '$id',\n })\n\n /*\n * The Campaign filter's choices: the campaigns placed on this site, by\n * name, and any campaign a clause names that the list does not, by its id,\n * so the chip can still be read and cleared.\n *\n * Read once the reader opens the Filters panel, not on every visit: the\n * names cost the org lookup and up to fifty campaign documents, which a\n * reader who came to open one form would pay for a filter they never\n * touched. Until they land the column has no choices, and the panel lists\n * it a moment later.\n */\n const [wantsCampaigns, setWantsCampaigns] = useState(false)\n const siteCampaigns = useHostCampaigns(hostId, {\n enabled: wantsCampaigns || formClauses.some((clause) => clause.field === 'campaignIds'),\n })\n const formFilterOptions = useMemo<Record<string, ListFilterOption[]>>(() => {\n const named = siteCampaigns.options.map((option) => ({\n value: option.value,\n label: option.label,\n }))\n const unnamed = formClauses\n .filter((clause) => clause.field === 'campaignIds')\n .flatMap((clause) => clause.value.split(','))\n .map((value) => value.trim())\n .filter((value) => value && !named.some((option) => option.value === value))\n .map((value) => ({ value, label: value }))\n return {\n status: FORM_STATUS_OPTIONS,\n leadRouting: FORM_LEAD_ROUTING_OPTIONS,\n campaignIds: [...named, ...unnamed],\n inCampaign: FORM_IN_CAMPAIGN_OPTIONS,\n }\n }, [siteCampaigns.options, formClauses])\n\n /*\n * The COUNT is a server aggregate, not the length of a page. `forms` is one\n * page, and publishing that as the site's form count would read as room to\n * spare on a site that is already at the ceiling.\n */\n const liveFormCount = useLiveArtifactCount(hostId, 'forms')\n // Pending or refused, the page stands in: a LOWER bound, never a confident\n // zero.\n const formsUsed = liveFormCount ?? forms.length\n /*\n * How many forms are retired, for the \"keep their slot\" line (AGL-2674).\n * An aggregate, because the list no longer reads a retired row unless it is\n * asked for one; asked again after this card retires or restores a form.\n */\n const [retiredCount, setRetiredCount] = useState<number | null>(null)\n useEffect(() => {\n let active = true\n getCountFromServer(query(formsCollection, where('retired', '==', true)))\n .then((snapshot) => {\n if (active) setRetiredCount(snapshot.data().count)\n })\n .catch(() => {\n // The line is a courtesy; without the count it is simply not shown.\n })\n return () => {\n active = false\n }\n }, [formsCollection, retiredEpoch])\n\n /**\n * Name first, then create (AGL-700).\n *\n * A form is created with BOTH halves seeded. `fields` is the declaration the\n * submission path reads and starts empty; the canvas is the design, seeded\n * with a root and a form node already bound to this id — so the besigner\n * opens on something that satisfies `checkFormContract` rather than on a\n * blank page whose first publish is a list of violations.\n */\n const handleCreate = useCallback(\n async (values: Record<string, any>) => {\n if (creating) return\n setCreating(true)\n setCreateError(null)\n try {\n const formId = Aglyn.createResourceUid()\n await createHostResource({\n hostId,\n resource: 'form',\n id: formId,\n data: {\n displayName: values['displayName'],\n slug: Aglyn.normalizeFormSlug(values['displayName']) || formId,\n fields: [],\n rootId: Aglyn.CANVAS_ROOT_ELEMENT_ID,\n nodes: {\n [Aglyn.CANVAS_ROOT_ELEMENT_ID]: {\n $id: Aglyn.CANVAS_ROOT_ELEMENT_ID,\n componentId: 'div',\n nodes: ['formRoot'],\n },\n formRoot: {\n $id: 'formRoot',\n componentId: 'form',\n pluginId: BUNDLE_ID,\n parentId: Aglyn.CANVAS_ROOT_ELEMENT_ID,\n props: { formId, formName: values['displayName'] },\n nodes: [],\n },\n },\n },\n })\n setCreateOpen(false)\n router.push(formHref(formId))\n } catch (error) {\n console.error(error)\n setCreateError('Could not create that form')\n } finally {\n setCreating(false)\n }\n },\n [creating, createHostResource, hostId, router, formHref],\n )\n\n const columns: GridColDef[] = [\n {\n field: 'displayName',\n headerName: 'Display name',\n minWidth: 220,\n type: 'string',\n renderCell: ({ id, value }: any) => (\n <AppLink href={formHref(id as string)}>\n {value || (id as string)}\n </AppLink>\n ),\n },\n {\n field: 'slug',\n headerName: 'Slug',\n minWidth: 160,\n flex: 1,\n type: 'string',\n // Blank reads as a rendering gap; '--' reads as \"nothing here\".\n valueFormatter: (value: any) => value || '--',\n },\n {\n field: 'submissions',\n headerName: 'Submissions',\n minWidth: 130,\n type: 'number',\n // Head AND body. `type: 'number'` right-aligns both, and the explicit\n // pair says so at the call site rather than relying on a grid default\n // that a later `renderCell` would silently override.\n align: 'right',\n headerAlign: 'right',\n valueGetter: (_value: any, row: any) => row?.stats?.submissions ?? null,\n valueFormatter: (value: any) =>\n typeof value === 'number' ? value.toLocaleString() : '--',\n },\n {\n field: 'leads',\n headerName: 'Leads',\n minWidth: 100,\n type: 'number',\n align: 'right',\n headerAlign: 'right',\n /*\n * A dash rather than a `0` whenever the field holds no number.\n *\n * `stats.leads` is counted only for a form whose `routing.lead` is set\n * (`0` there until its first lead), so a form that has never routed to\n * leads has no recorded figure. Rendering `0` there would read as \"this\n * form has produced no leads\", which is a claim about a measurement\n * nobody took.\n */\n valueGetter: (_value: any, row: any) =>\n typeof row?.stats?.leads === 'number' ? row.stats.leads : null,\n valueFormatter: (value: any) =>\n typeof value === 'number' ? value.toLocaleString() : '--',\n },\n {\n field: 'lastSubmission',\n headerName: 'Last submission',\n minWidth: 170,\n flex: 1,\n type: 'date',\n valueGetter: (_value: any, row: any) =>\n typeof row?.stats?.lastSubmissionAtMs === 'number'\n ? new Date(row.stats.lastSubmissionAtMs)\n : null,\n valueFormatter: (value: any) => value?.toLocaleString?.() || '--',\n },\n {\n field: 'updatedAt',\n headerName: 'Updated',\n minWidth: 170,\n flex: 1,\n type: 'date',\n // MUI X v9 passes the value positionally. The v6 object form silently\n // destructures undefined off a Date and every row renders '--'.\n valueGetter: (value: any) => value?.toDate?.() ?? null,\n valueFormatter: (value: any) => value?.toLocaleString?.() || '--',\n },\n /*\n * Status and Lead routing, hidden until Columns shows them (AGL-3330).\n * Each is a select the Filters panel offers either way, and draws the\n * choice its value names. Status reads the FACT, `archivedAt`, rather\n * than the `retired` mirror the query asks, so a form the backfill has\n * not reached yet still reads true.\n */\n {\n field: 'status',\n headerName: 'Status',\n minWidth: 110,\n valueGetter: (_value: any, row: any) => String(isFormArchived(row)),\n },\n {\n field: 'leadRouting',\n headerName: 'Lead routing',\n minWidth: 120,\n valueGetter: (_value: any, row: any) => String(row?.routing?.lead === true),\n },\n /*\n * In a campaign, hidden like the two above. Reads the ids the form is\n * filed under — the fact `inCampaign` mirrors for the query — so a form\n * the backfill has not reached still draws the truth.\n */\n {\n field: 'inCampaign',\n headerName: 'In a campaign',\n minWidth: 130,\n valueGetter: (_value: any, row: any) =>\n String(Aglyn.readCampaignIds(row).length > 0),\n },\n listActionsColumn((row: any) => {\n const form = { ...row, $id: row.$id as string }\n const versionId = form.versionId as string | undefined\n return (\n <ListRowActions\n label={form.displayName ?? form.$id}\n quick={{\n icon: mdiEyeOutline.path,\n label: 'Preview',\n // A form with no version has never been opened in the besigner, so\n // there is no snapshot to render. Disabled and saying so, rather\n // than a link to an empty preview.\n ...(versionId && orgSlug && host\n ? {\n to: buildRoute(Route.FORM_PREVIEW, {\n orgSlug,\n host,\n formId: form.$id,\n versionId,\n }),\n }\n : {\n unavailableReason: versionId\n ? 'Resolving this site’s address…'\n : 'Nothing to preview yet — open it in the besigner once.',\n }),\n }}\n items={[\n {\n key: 'details',\n label: 'View details',\n icon: <MdiIcon path={ICON_VARIANT_SHOW_DETAIL.path} size={0.8} />,\n href: formHref(form.$id),\n },\n {\n key: 'besigner',\n label: 'Edit in besigner',\n icon: <MdiIcon path={mdiVectorSquare.path} size={0.8} />,\n /*\n * A LINK only once the form has a version. A form that has never\n * been opened has none, and opening it MINTS the first one —\n * that is a write, and the detail page is the one place that\n * decides what an initial version looks like. Sending the reader\n * there rather than minting a second way is what keeps the two\n * from drifting.\n */\n href:\n versionId && orgSlug && host\n ? buildRoute(Route.FORM_BESIGNER, {\n orgSlug,\n host,\n formId: form.$id,\n versionId,\n })\n : formHref(form.$id),\n },\n /*\n * Retire / Restore (AGL-2671). Last in the menu, and the only item\n * here that writes.\n *\n * The label says what it DOES to the form rather than what it does\n * to this list — \"Archive\" reads like a filing action, and the\n * consequence a merchant needs to weigh is that the form stops\n * collecting. `/api/forms/submit` refuses a retired form, so one\n * still placed on a published page will turn visitors away.\n */\n {\n key: 'duplicate',\n label: DUPLICATE_MENU_LABEL,\n icon: <MdiIcon path={mdiContentCopy.path} size={0.8} />,\n onClick: () =>\n duplicate.request('form', {\n id: form.$id,\n name: form.displayName ?? '',\n }),\n },\n isFormArchived(form)\n ? {\n key: 'restore',\n label: 'Restore — start collecting again',\n icon: <MdiIcon path={mdiArchiveArrowUpOutline.path} size={0.8} />,\n onClick: () => void setRetired(form.$id, false),\n }\n : {\n key: 'retire',\n label: 'Retire — stop collecting, keep the history',\n icon: <MdiIcon path={mdiArchiveArrowDownOutline.path} size={0.8} />,\n onClick: () => void setRetired(form.$id, true),\n },\n ]}\n />\n )\n }),\n ]\n\n return (\n <>\n {duplicate.dialog}\n {/*\n The readout leads the create button, in the PAGE header — where Sites,\n screens, layouts, components and templates put theirs. Forms declares\n no sections, so it has no vertical rail for a card-header cluster to\n belong beside, and the controls are about the whole surface rather\n than about anything the card is showing.\n Published from the card because the card owns what they say: the count\n comes from the listener already open here, and a page that counted for\n itself would be a second source for one fact and a second read for one\n collection. It publishes from the LIST, so a form's own detail route —\n a different component — leaves the header with nothing to create into.\n */}\n <PageHeaderActions>\n <Stack direction=\"row\" spacing={2} sx={{ alignItems: 'center' }}>\n {/*\n The denominator is `formsPerHost` — the allowance\n `/api/hosts/resources` refuses the create at, resolved through the\n same `checkQuota` the route calls, so the number offered and the\n number enforced cannot drift apart. `FORMS_MAX_PER_HOST` bounds a\n READ of the catalog and sits above every plan's allowance, so\n quoting it advertises room the server will not honor.\n */}\n <QuotaReadoutComponent\n ready={org != null}\n used={formsUsed}\n limit={Aglyn.checkQuota(org, 'formsPerHost', formsUsed).limit}\n noun=\"form\"\n />\n {/*\n A retired form keeps its slot, and the readout says so rather than\n leaving a merchant to work out why a catalog of three is using five\n (AGL-2674). The count comes from the same aggregate\n `/api/hosts/resources` enforces on, which subtracts deleted forms\n and nothing else — so retirement is deliberately not a way to buy\n room back, and the number here is the number the server will\n refuse a create at.\n\n Shown only when the site has retired a form (an aggregate — the\n list reads no retired row unless asked): the line answers a\n question nobody is asking on a site that has never retired\n anything.\n */}\n {retiredCount ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Retired forms keep their slot'}\n </Typography>\n ) : null}\n <Stack direction=\"row\" spacing={1}>\n {/*\n Other ways to start a form, from plugins (AGL-3043): the\n `hostForms` zone, drawn through the shell's own gated slot. A\n plugin page cannot mount that slot itself, so the shell hands it\n down, and a widget here passes the gates a console page's would.\n */}\n {CreateZone ? (\n <CreateZone slot=\"hostForms\" hostId={hostId} orgId={org?.$id} />\n ) : null}\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={creating}\n onClick={() => {\n setCreateError(null)\n setCreateOpen(true)\n }}\n >\n {creating ? 'Creating…' : 'Create Form'}\n </Button>\n </Stack>\n </Stack>\n </PageHeaderActions>\n <CardDisplay\n header=\"Forms\"\n help={pluginDocsHelp('forms', {\n anchor: '#find-a-form',\n excerpt:\n 'A form collects submissions into the Inbox, and its design is ' +\n 'drawn in the besigner like any other artifact.',\n })}\n >\n {retireError ? (\n <Alert severity=\"error\" sx={{ mb: 2 }}>\n {retireError}\n </Alert>\n ) : null}\n <ListFilterChips\n fields={FORM_LIST_FILTER_FIELDS}\n headers={FORM_LIST_FILTER_HEADERS}\n clauses={gridFilter.clauses}\n onChange={gridFilter.setClauses}\n options={formFilterOptions}\n />\n <ListQueryNotices\n refused={listQueryRefusals(plan.refused, {\n fields: FORM_LIST_FILTER_FIELDS,\n headers: FORM_LIST_FILTER_HEADERS,\n options: formFilterOptions,\n })}\n notices={plan.notices}\n />\n <ListTable\n rowHeight={TABLE_ROW_HEIGHT}\n columns={listFilterGridColumns(\n columns,\n FORM_LIST_FILTER_FIELDS,\n formFilterOptions,\n FORM_LIST_FILTER_HEADERS,\n )}\n initialState={{ columns: { columnVisibilityModel: FORM_HIDDEN_COLUMNS } }}\n /*\n * The grid must NOT also filter. The query answers the panel and\n * the search, so a second pass in the browser could only drop rows\n * that already matched — it compares what a column DRAWS, and the\n * query compares what the document stores.\n */\n filterMode=\"server\"\n filterModel={gridFilter.filterModel}\n onFilterModelChange={gridFilter.onFilterModelChange}\n quickFilter\n // The Campaign filter's names are read once the panel is opened.\n onPreferencePanelOpen={(params) => {\n if (params.openedPanelValue === 'filters') setWantsCampaigns(true)\n }}\n {...(filtering\n ? { noRowsLabel: 'No forms match these filters' }\n : {\n noRowsLabel: 'No forms yet',\n noRowsDescription:\n 'A form collects submissions, dedupes the people who send them, and can route them to a lead. Its design is drawn in the besigner and published like any other artifact.',\n noRowsAction: (\n <Button variant=\"contained\" onClick={() => setCreateOpen(true)}>\n {'Create your first form'}\n </Button>\n ),\n })}\n rows={forms}\n // The whole row opens the detail page; the action cluster stops\n // propagation so a menu click never navigates underneath it.\n onOpen={(id) => router.push(formHref(String(id)))}\n // An empty table while the read is in flight reads as \"you have none\"\n // rather than \"these are on their way\".\n loading={status === 'loading'}\n // Paged by the footer below, so the grid must not also slice.\n hideFooter\n // The rows keep the query's order; a header sort would order only\n // the page on screen and read as the whole list's.\n disableColumnSorting\n />\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={forms.length}\n hasMore={hasMore}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n {/*\n The console's own create drawer, from the shared library rather than a\n second one that looks like it. The empty state and the header open the\n SAME one, so the state that says \"creating…\" is one state.\n */}\n <CreateArtifactDrawer\n open={createOpen}\n onClose={() => setCreateOpen(false)}\n title=\"Create new form\"\n // A form document stores no description: `/api/hosts/resources` filters\n // `data` through a per-kind allow-list, so one typed here is dropped\n // without a word.\n includeDescription={false}\n onSubmit={handleCreate}\n errorSlot={\n createError ? (\n <Alert severity=\"error\" sx={{ mt: 2, mb: 1 }}>\n {createError}\n </Alert>\n ) : null\n }\n />\n </CardDisplay>\n </>\n )\n}\nHostFormsCard.displayName = 'HostFormsCard'\n\nexport default HostFormsCard\n"],"names":["Aglyn","buildRoute","isFormArchived","PageHeaderActions","pluginDocsHelp","Route","useConsoleWidgetSlot","ICON_VARIANT_SHOW_DETAIL","mdiArchiveArrowDownOutline","mdiArchiveArrowUpOutline","mdiContentCopy","mdiEyeOutline","mdiVectorSquare","AppLink","CardDisplay","MdiIcon","ListFilterChips","ListPagination","ListQueryNotices","listQueryRefusals","ListTable","ListRowActions","listActionsColumn","hiddenFilterVisibility","listFilterGridColumns","TABLE_ROW_HEIGHT","useListGridFilter","QuotaReadoutComponent","CreateArtifactDrawer","Timestamp","Alert","Button","Stack","Typography","collection","doc","getCountFromServer","query","updateDoc","where","DUPLICATE_MENU_LABEL","useConsoleHostRoute","useDuplicateResource","useFirestore","useHostCampaigns","useHostResourceApi","useLiveArtifactCount","useListQuery","useRouter","useCallback","useEffect","useMemo","useState","BUNDLE_ID","FORM_IN_CAMPAIGN_OPTIONS","FORM_LEAD_ROUTING_OPTIONS","FORM_LIST_FILTER_FIELDS","FORM_LIST_FILTER_HEADERS","FORM_LIST_QUERY","FORM_LIST_SELECT_FIELDS","FORM_STATUS_OPTIONS","formListRequest","FORM_VISIBLE_FILTER_COLUMNS","FORM_HIDDEN_COLUMNS","HostFormsCard","props","hostId","basePath","org","router","duplicate","onDuplicated","_kind","copy","push","formHref","id","orgSlug","subdomain","host","firestore","createHostResource","CreateZone","formId","FORM_DETAILS","createOpen","setCreateOpen","createError","setCreateError","creating","setCreating","retireError","setRetireError","retiredEpoch","setRetiredEpoch","setRetired","retired","archivedAt","Date","now","updatedAt","epoch","formClauses","setFormClauses","gridFilter","clauses","onChange","selectFields","filtering","length","searchWords","formsCollection","status","rows","forms","hasMore","page","setPage","pageSize","setPageSize","plan","declaration","request","deps","idField","wantsCampaigns","setWantsCampaigns","siteCampaigns","enabled","some","clause","field","formFilterOptions","named","options","map","option","value","label","unnamed","filter","flatMap","split","trim","leadRouting","campaignIds","inCampaign","liveFormCount","formsUsed","retiredCount","setRetiredCount","active","then","snapshot","data","count","catch","handleCreate","values","createResourceUid","resource","displayName","slug","normalizeFormSlug","fields","rootId","CANVAS_ROOT_ELEMENT_ID","nodes","$id","componentId","formRoot","pluginId","parentId","formName","error","console","columns","headerName","minWidth","type","renderCell","href","flex","valueFormatter","align","headerAlign","valueGetter","_value","row","stats","submissions","toLocaleString","leads","lastSubmissionAtMs","toDate","String","routing","lead","readCampaignIds","form","versionId","quick","icon","path","to","FORM_PREVIEW","unavailableReason","items","key","size","FORM_BESIGNER","onClick","name","dialog","direction","spacing","sx","alignItems","ready","used","limit","checkQuota","noun","variant","color","slot","orgId","disabled","header","help","anchor","excerpt","severity","mb","headers","setClauses","refused","notices","rowHeight","initialState","columnVisibilityModel","filterMode","filterModel","onFilterModelChange","quickFilter","onPreferencePanelOpen","params","openedPanelValue","noRowsLabel","noRowsDescription","noRowsAction","onOpen","loading","hideFooter","disableColumnSorting","rowCount","onPageChange","onPageSizeChange","open","onClose","title","includeDescription","onSubmit","errorSlot","mt"],"mappings":"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"}
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /** This plugin's recount route — see `formStatsHandler` in `../server.ts`. */
18
+ export declare const FORM_STATS_API_PATH = "/api/forms/stats";
19
+ /** The forms whose counters to recount, on one site. */
20
+ export interface FormStatsRecountTarget {
21
+ hostId: string;
22
+ formIds: readonly string[];
23
+ }
24
+ /**
25
+ * Asks `/api/forms/stats` to recount forms' counters from their rows
26
+ * (AGL-3330) — what a console act that changes what they should say calls
27
+ * afterwards: switching lead routing, deleting a submission.
28
+ *
29
+ * A client cannot write a form's `stats` (the rules refuse it), so the
30
+ * server counts and writes. BEST EFFORT, and it never throws: the act that
31
+ * prompted it has already happened, and a recount that did not land leaves
32
+ * the counters exactly as the increment left them, for the next recount —
33
+ * `tools/scripts/recount-form-stats.mjs` over every form — to put right.
34
+ *
35
+ * @returns whether the recount was answered.
36
+ */
37
+ export declare function useFormStatsRecountApi(): (target: FormStatsRecountTarget) => Promise<boolean>;
38
+ export default useFormStatsRecountApi;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ 'use client';
17
+ import { usePluginApiPost } from "@aglyn/tenant-feature-instance";
18
+ import { useCallback } from "react";
19
+ /** This plugin's recount route — see `formStatsHandler` in `../server.ts`. */ export const FORM_STATS_API_PATH = '/api/forms/stats';
20
+ /**
21
+ * Asks `/api/forms/stats` to recount forms' counters from their rows
22
+ * (AGL-3330) — what a console act that changes what they should say calls
23
+ * afterwards: switching lead routing, deleting a submission.
24
+ *
25
+ * A client cannot write a form's `stats` (the rules refuse it), so the
26
+ * server counts and writes. BEST EFFORT, and it never throws: the act that
27
+ * prompted it has already happened, and a recount that did not land leaves
28
+ * the counters exactly as the increment left them, for the next recount —
29
+ * `tools/scripts/recount-form-stats.mjs` over every form — to put right.
30
+ *
31
+ * @returns whether the recount was answered.
32
+ */ export function useFormStatsRecountApi() {
33
+ const post = usePluginApiPost();
34
+ return useCallback(async ({ hostId, formIds })=>{
35
+ const ids = [
36
+ ...new Set(formIds.filter(Boolean))
37
+ ];
38
+ if (!hostId || !ids.length) return false;
39
+ return post(FORM_STATS_API_PATH, {
40
+ hostId,
41
+ formIds: ids
42
+ });
43
+ }, [
44
+ post
45
+ ]);
46
+ }
47
+ export default useFormStatsRecountApi;
48
+
49
+ //# sourceMappingURL=use-form-stats-recount-api.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/forms/src/lib/components/use-form-stats-recount-api.ts"],"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\n'use client'\n\nimport { usePluginApiPost } from '@aglyn/tenant-feature-instance'\nimport { useCallback } from 'react'\n\n/** This plugin's recount route — see `formStatsHandler` in `../server.ts`. */\nexport const FORM_STATS_API_PATH = '/api/forms/stats'\n\n/** The forms whose counters to recount, on one site. */\nexport interface FormStatsRecountTarget {\n hostId: string\n formIds: readonly string[]\n}\n\n/**\n * Asks `/api/forms/stats` to recount forms' counters from their rows\n * (AGL-3330) — what a console act that changes what they should say calls\n * afterwards: switching lead routing, deleting a submission.\n *\n * A client cannot write a form's `stats` (the rules refuse it), so the\n * server counts and writes. BEST EFFORT, and it never throws: the act that\n * prompted it has already happened, and a recount that did not land leaves\n * the counters exactly as the increment left them, for the next recount —\n * `tools/scripts/recount-form-stats.mjs` over every form — to put right.\n *\n * @returns whether the recount was answered.\n */\nexport function useFormStatsRecountApi(): (target: FormStatsRecountTarget) => Promise<boolean> {\n const post = usePluginApiPost()\n return useCallback(\n async ({ hostId, formIds }) => {\n const ids = [...new Set(formIds.filter(Boolean))]\n if (!hostId || !ids.length) return false\n return post(FORM_STATS_API_PATH, { hostId, formIds: ids })\n },\n [post],\n )\n}\n\nexport default useFormStatsRecountApi\n"],"names":["usePluginApiPost","useCallback","FORM_STATS_API_PATH","useFormStatsRecountApi","post","hostId","formIds","ids","Set","filter","Boolean","length"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;AAEA,SAASA,gBAAgB,QAAQ,iCAAgC;AACjE,SAASC,WAAW,QAAQ,QAAO;AAEnC,4EAA4E,GAC5E,OAAO,MAAMC,sBAAsB,mBAAkB;AAQrD;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC;IACd,MAAMC,OAAOJ;IACb,OAAOC,YACL,OAAO,EAAEI,MAAM,EAAEC,OAAO,EAAE;QACxB,MAAMC,MAAM;eAAI,IAAIC,IAAIF,QAAQG,MAAM,CAACC;SAAU;QACjD,IAAI,CAACL,UAAU,CAACE,IAAII,MAAM,EAAE,OAAO;QACnC,OAAOP,KAAKF,qBAAqB;YAAEG;YAAQC,SAASC;QAAI;IAC1D,GACA;QAACH;KAAK;AAEV;AAEA,eAAeD,uBAAsB"}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type PluginEventPayloads } from '@aglyn/aglyn/plugin-manager/plugin-events';
18
+ /**
19
+ * The forms each removed record was counted by, per site (AGL-3330): a
20
+ * submission names its form by `formId` on its own site, and a lead names
21
+ * the forms that filed it by `form:{formId}` in `sources`, on any of the
22
+ * organization's sites.
23
+ */
24
+ export declare function formsCountingRemovedRecords(payload: PluginEventPayloads['host.records.removed']): Array<{
25
+ hostId: string;
26
+ formIds: string[];
27
+ }>;
28
+ /**
29
+ * The forms plugin's server declarations: the light registrations core reads
30
+ * at boot, before any surface loads.
31
+ *
32
+ * A form's counters are this plugin's to keep (AGL-3330), and a row they
33
+ * counted can be removed by a path that is not the plugin's — the public
34
+ * API's delete, a person's erasure. Core says so through
35
+ * `host.records.removed`, and this recounts each form the removed rows fed.
36
+ * The recount module (and the Admin SDK with it) loads on the first such
37
+ * event, not at boot. A site that does not hold a named form answers nothing
38
+ * for one document read, so a lead's forms are looked for on each site the
39
+ * event names.
40
+ */
41
+ export declare function registerFormsServerDeclarations(): void;