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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,120 +17,43 @@
17
17
  import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
19
19
  import * as Aglyn from "@aglyn/aglyn";
20
- import { buildRoute, isFormArchived, PageHeaderActions, pluginDocsHelp, readCampaignIds, Route } from "@aglyn/aglyn";
20
+ import { buildRoute, isFormArchived, PageHeaderActions, pluginDocsHelp, Route } from "@aglyn/aglyn";
21
21
  import { useConsoleWidgetSlot } from "@aglyn/aglyn/app-utils/console-widget-slot-context";
22
22
  import { ICON_VARIANT_SHOW_DETAIL } from "@aglyn/shared-data-enums";
23
23
  import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline, mdiContentCopy, mdiEyeOutline, mdiVectorSquare } from "@aglyn/shared-data-mdi";
24
24
  import { AppLink, CardDisplay, MdiIcon } from "@aglyn/shared-ui-jsx";
25
25
  import ListFilterChips from "@aglyn/shared-ui-jsx/components/list-filter-chips.component";
26
26
  import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
27
+ import ListQueryNotices, { listQueryRefusals } from "@aglyn/shared-ui-jsx/components/list-query-notices.component";
27
28
  import ListTable, { ListRowActions, listActionsColumn } from "@aglyn/shared-ui-jsx/components/list-table.component";
28
- import { inMemoryListField } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
29
+ import { hiddenFilterVisibility } from "@aglyn/shared-ui-jsx/const/list-filter";
30
+ import { listFilterGridColumns } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
29
31
  import { TABLE_ROW_HEIGHT } from "@aglyn/shared-ui-jsx/const/table-pagination";
30
- import { usePagedRowsFilter } from "@aglyn/shared-ui-jsx/hooks/use-paged-rows-filter";
32
+ import { useListGridFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-grid-filter";
31
33
  import QuotaReadoutComponent from "@aglyn/shared-ui-jsx/components/quota-readout.component";
32
34
  import { CreateArtifactDrawer } from "@aglyn/shared-ui-jsx-forms";
35
+ import { Timestamp } from "@aglyn/shared-util-timestamp";
33
36
  import { Alert, Button, Stack, Typography } from "@mui/material";
34
- import { collection, doc, updateDoc } from "firebase/firestore";
35
- import { DUPLICATE_MENU_LABEL, useConsoleHostRoute, useDuplicateResource, useFirestore, useHostCampaigns, useHostResourceApi, useLiveArtifactCount, usePagedCollection } from "@aglyn/tenant-feature-instance";
36
- import { collectionPage } from "@aglyn/tenant-feature-instance/hooks/host-collection-queries";
37
+ import { collection, doc, getCountFromServer, query, updateDoc, where } from "firebase/firestore";
38
+ import { DUPLICATE_MENU_LABEL, useConsoleHostRoute, useDuplicateResource, useFirestore, useHostCampaigns, useHostResourceApi, useLiveArtifactCount } from "@aglyn/tenant-feature-instance";
39
+ import { useListQuery } from "@aglyn/tenant-feature-instance/hooks/use-list-query";
37
40
  import { useRouter } from "next/navigation";
38
- import { useCallback, useMemo, useState } from "react";
41
+ import { useCallback, useEffect, useMemo, useState } from "react";
39
42
  import { BUNDLE_ID } from "../constants/bundle-common.js";
40
- /*
41
- * What the forms grid's Filters panel offers (AGL-3317, AGL-3330): every
42
- * column the table shows, typed, and the stored fields a site sorts its
43
- * forms out by, as columns kept out of sight.
44
- *
45
- * EVERY ONE NARROWS THE LOADED WINDOW; none is served. The list is a paged
46
- * listener over the document id (`collectionPage`), so a filter matches over
47
- * what the window has read, which the first filter widens
48
- * (`usePagedRowsFilter`) and the caption under the chips owns up to. The
49
- * query never changes shape, so no filter here needs an index. The figures
50
- * are the `stats` counters `/api/forms/submit` keeps on the form document
51
- * itself, read with the row.
52
- *
53
- * - Submissions and Leads are numbers. Leads is ABSENT on a form that does
54
- * not route to leads — the column's dash — so `is empty` finds those and
55
- * `= 0` finds a routed form that has produced none.
56
- * - Last submission, Updated and Created are days. Created is a column
57
- * hidden by default: `/api/hosts/resources` stamps it on every form it
58
- * makes.
59
- * - Status (active or retired), Lead routing (`routing.lead`) and Campaign
60
- * (`campaignIds`) are hidden columns, matched on keys derived by
61
- * {@link formFilterRow} so the row keeps the stored fields its menu reads.
62
- * A form's campaigns are ids, matched member by member and byte for byte,
63
- * as the campaigns list matches its `listIds`.
64
- *
65
- * Nothing else a form stores is a filter. It keeps no type and no template,
66
- * and its plan slot is not its own: a retired form keeps one, which is the
67
- * Status filter's answer.
68
- */ export const FORM_FILTER_FIELDS = [
69
- inMemoryListField('displayName', 'text'),
70
- inMemoryListField('slug', 'text'),
71
- inMemoryListField('submissions', 'number', 'stats.submissions'),
72
- inMemoryListField('leads', 'number', 'stats.leads'),
73
- inMemoryListField('lastSubmission', 'date', 'stats.lastSubmissionAtMs'),
74
- inMemoryListField('updatedAt', 'date'),
75
- inMemoryListField('createdAt', 'date'),
76
- inMemoryListField('status', 'select', 'statusKey'),
77
- inMemoryListField('leadRouting', 'select', 'leadRoutingKey'),
78
- _extends({}, inMemoryListField('campaignIds', 'select', 'campaignKeys'), {
79
- tokensPath: 'campaignKeys',
80
- verbatimTokens: true
81
- })
82
- ];
83
- export const FORM_FILTER_HEADERS = {
84
- displayName: 'Display name',
85
- slug: 'Slug',
86
- submissions: 'Submissions',
87
- leads: 'Leads',
88
- lastSubmission: 'Last submission',
89
- updatedAt: 'Updated',
90
- createdAt: 'Created',
91
- status: 'Status',
92
- leadRouting: 'Lead routing',
93
- campaignIds: 'Campaign'
94
- };
95
- const FORM_STATUS_OPTIONS = [
96
- {
97
- value: 'active',
98
- label: 'Active'
99
- },
100
- {
101
- value: 'retired',
102
- label: 'Retired'
103
- }
104
- ];
105
- const FORM_LEAD_ROUTING_OPTIONS = [
106
- {
107
- value: 'on',
108
- label: 'On'
109
- },
110
- {
111
- value: 'off',
112
- label: 'Off'
113
- }
114
- ];
115
- /** What the quick search reads: the name, the slug and the form's id. */ export const FORM_SEARCH_FIELDS = [
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";
44
+ /**
45
+ * The filterable columns the table shows. The rest of the declaration —
46
+ * Status, Lead routing, Campaign, In a campaign — reaches the Filters panel as hidden
47
+ * columns (`hiddenFilterColumns`).
48
+ */ const FORM_VISIBLE_FILTER_COLUMNS = [
116
49
  'displayName',
117
50
  'slug',
118
- '$id'
51
+ 'submissions',
52
+ 'leads',
53
+ 'lastSubmission',
54
+ 'updatedAt'
119
55
  ];
120
- const FORM_HIDDEN_COLUMNS = {
121
- createdAt: false,
122
- status: false,
123
- leadRouting: false,
124
- campaignIds: false
125
- };
126
- /** A form row with the keys its hidden filters match on. */ export const formFilterRow = (form)=>{
127
- var _form_routing;
128
- return _extends({}, form, {
129
- statusKey: isFormArchived(form) ? 'retired' : 'active',
130
- leadRoutingKey: (form == null ? void 0 : (_form_routing = form.routing) == null ? void 0 : _form_routing.lead) === true ? 'on' : 'off',
131
- campaignKeys: readCampaignIds(form)
132
- });
133
- };
56
+ const FORM_HIDDEN_COLUMNS = hiddenFilterVisibility(FORM_LIST_FILTER_FIELDS, FORM_VISIBLE_FILTER_COLUMNS);
134
57
  /**
135
58
  * THE FORM CATALOG, WITH ITS SHAPE VISIBLE BEFORE THERE IS ANYTHING IN IT.
136
59
  *
@@ -153,11 +76,13 @@ const FORM_HIDDEN_COLUMNS = {
153
76
  * ## The two numeric columns, and when one is honestly blank
154
77
  *
155
78
  * `stats.submissions` and `stats.leads` are both incremented by
156
- * `/api/forms/submit` on a write that was happening anyway, so both are real
157
- * numbers. `leads` is counted only for a form whose `routing.lead` is set, so
158
- * a form that does not route to leads carries no figure at all and renders as
159
- * a dash rather than as `0`. A zero would say this form has produced no leads,
160
- * 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.
161
86
  */ export function HostFormsCard(props) {
162
87
  const { hostId, basePath, org } = props;
163
88
  const router = useRouter();
@@ -193,6 +118,7 @@ const FORM_HIDDEN_COLUMNS = {
193
118
  const [createError, setCreateError] = useState(null);
194
119
  const [creating, setCreating] = useState(false);
195
120
  const [retireError, setRetireError] = useState(null);
121
+ /** Bumped by each retire or restore, so the retired count is asked again. */ const [retiredEpoch, setRetiredEpoch] = useState(0);
196
122
  /**
197
123
  * Retire a form, or put it back (AGL-2671).
198
124
  *
@@ -213,8 +139,15 @@ const FORM_HIDDEN_COLUMNS = {
213
139
  setRetireError(null);
214
140
  try {
215
141
  await updateDoc(doc(firestore, 'hosts', hostId, 'forms', formId), {
216
- archivedAt: retired ? Date.now() : null
142
+ archivedAt: retired ? Date.now() : null,
143
+ // The mirror the list's query asks (AGL-3330).
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()
217
149
  });
150
+ setRetiredEpoch((epoch)=>epoch + 1);
218
151
  } catch (unused) {
219
152
  setRetireError(retired ? 'Could not retire that form. Try again shortly.' : 'Could not restore that form. Try again shortly.');
220
153
  }
@@ -222,116 +155,110 @@ const FORM_HIDDEN_COLUMNS = {
222
155
  firestore,
223
156
  hostId
224
157
  ]);
225
- /**
226
- * The list PAGES, over an ordered walk.
158
+ /*
159
+ * THE LIST IS ITS QUERY (AGL-3330, on the AGL-3321 list query plan).
160
+ *
161
+ * The grid's Filters panel and search box edit the clauses and the words;
162
+ * `useListQuery` puts every one of them on ONE Firestore query and pages
163
+ * that query, so a page is a page of matches and a form whose only match
164
+ * is past the first page is still found. What one query cannot hold — a
165
+ * second range, a second array clause — is refused by name above the list
166
+ * (`ListQueryNotices`) rather than matched over the rows read. See
167
+ * `form-list-query.ts` for every field and the indexes they cost.
227
168
  *
228
- * `collectionPage` holds the ordering decision and the reason it is the
229
- * document id rather than `displayName` — briefly, `orderBy` matches only
230
- * documents that HAVE the field, and the resources route stores an
231
- * allow-list it never checks for presence, so ordering on a name would hide
232
- * every form created without one rather than mis-sorting the list.
169
+ * Unfiltered it reads in document order, as it always has: `orderBy` on a
170
+ * name would drop every form stored without one rather than mis-sort it.
233
171
  *
234
- * The page is NOT re-sorted: sorting a server-ordered window in the browser
235
- * is what makes a pseudo-random sample look like the first page.
236
- */ const { status, data: formData, rows: formWindow, hasMore, page, setPage, pageSize, setPageSize } = usePagedCollection((pageLimit)=>collectionPage(collection(firestore, 'hosts', hostId, 'forms'), pageLimit), [
172
+ * A retired form is a TOMBSTONE, not a row, unless the reader asks for
173
+ * Status (AGL-2671). The query leaves them out (`retired == false`), so a
174
+ * page is a page of forms in use; a Status clause replaces that with the
175
+ * reader's own choice. Retiring a form removes it from the list, and
176
+ * without the Status filter that would be a one-way door: the row is the
177
+ * only route back to Restore.
178
+ */ const [formClauses, setFormClauses] = useState([]);
179
+ const gridFilter = useListGridFilter({
180
+ clauses: formClauses,
181
+ onChange: setFormClauses,
182
+ selectFields: FORM_LIST_SELECT_FIELDS
183
+ });
184
+ const filtering = gridFilter.clauses.length > 0 || gridFilter.searchWords.length > 0;
185
+ const formsCollection = useMemo(()=>collection(firestore, 'hosts', hostId, 'forms'), [
237
186
  firestore,
238
187
  hostId
239
- ], {
188
+ ]);
189
+ const { status, rows: forms, hasMore, page, setPage, pageSize, setPageSize, plan } = useListQuery({
190
+ collection: formsCollection,
191
+ declaration: FORM_LIST_QUERY,
192
+ request: formListRequest(gridFilter.clauses, gridFilter.searchWords),
193
+ deps: [
194
+ firestore,
195
+ hostId
196
+ ],
240
197
  idField: '$id'
241
198
  });
242
199
  /*
243
- * A retired form is a TOMBSTONE, not a row — unless the reader asks for the
244
- * tombstones (AGL-2671). Client-side because it has to be: Firestore cannot
245
- * ask for the ABSENCE of a field, so a form created through the resources
246
- * route (which carries no `archivedAt` at all) and one archived later are
247
- * not one value to filter on. The cost is that a tombstone spends a slot in
248
- * whichever page it falls in.
249
- *
250
- * The Filters panel's Status is how the reader asks (AGL-3317). With no
251
- * Status clause the list leaves the tombstones out, as it always has, and
252
- * reads one page; a Status clause hands the panel every row the window
253
- * read, retired included, and the clause itself picks which to show. That
254
- * default is the absence of a clause rather than a clause the list starts
255
- * with, because a starting clause would widen the window on every visit to
256
- * answer a question nobody asked. Retiring a form removes it from the list,
257
- * and without the Status filter that would be a one-way door: the row is
258
- * the only route back to Restore.
259
- */ const [formClauses, setFormClauses] = useState([]);
260
- const askedStatus = formClauses.some((clause)=>clause.field === 'status');
261
- const formRows = useMemo(()=>formWindow.filter((form)=>askedStatus || !isFormArchived(form)).map(formFilterRow), [
262
- formWindow,
263
- askedStatus
264
- ]);
265
- const formWindowRows = useMemo(()=>formData == null ? void 0 : formData.filter((form)=>askedStatus || !isFormArchived(form)).map(formFilterRow), [
266
- formData,
267
- askedStatus
268
- ]);
269
- /*
270
200
  * The Campaign filter's choices: the campaigns placed on this site, by
271
- * name, and any campaign a loaded form is filed under that the site's list
272
- * does not name — one since taken off this site — by its id, so the form
273
- * filed under it can still be found.
201
+ * name, and any campaign a clause names that the list does not, by its id,
202
+ * so the chip can still be read and cleared.
274
203
  *
275
- * Read only once a loaded form IS filed under a campaign. The names cost
276
- * the org lookup and up to fifty campaign documents, which a catalog with
277
- * no filed form would pay on every visit for a filter with nothing to find;
278
- * there the column has no choices and the panel leaves it out.
279
- */ const filedCampaignIds = useMemo(()=>[
280
- ...new Set((formData != null ? formData : []).flatMap((form)=>readCampaignIds(form)))
281
- ], [
282
- formData
283
- ]);
204
+ * Read once the reader opens the Filters panel, not on every visit: the
205
+ * names cost the org lookup and up to fifty campaign documents, which a
206
+ * reader who came to open one form would pay for a filter they never
207
+ * touched. Until they land the column has no choices, and the panel lists
208
+ * it a moment later.
209
+ */ const [wantsCampaigns, setWantsCampaigns] = useState(false);
284
210
  const siteCampaigns = useHostCampaigns(hostId, {
285
- enabled: filedCampaignIds.length > 0
211
+ enabled: wantsCampaigns || formClauses.some((clause)=>clause.field === 'campaignIds')
286
212
  });
287
213
  const formFilterOptions = useMemo(()=>{
288
214
  const named = siteCampaigns.options.map((option)=>({
289
215
  value: option.value,
290
216
  label: option.label
291
217
  }));
292
- const unnamed = siteCampaigns.ready ? filedCampaignIds.filter((id)=>!named.some((option)=>option.value === id)).map((id)=>({
293
- value: id,
294
- label: id
295
- })) : [];
218
+ const unnamed = formClauses.filter((clause)=>clause.field === 'campaignIds').flatMap((clause)=>clause.value.split(',')).map((value)=>value.trim()).filter((value)=>value && !named.some((option)=>option.value === value)).map((value)=>({
219
+ value,
220
+ label: value
221
+ }));
296
222
  return {
297
223
  status: FORM_STATUS_OPTIONS,
298
224
  leadRouting: FORM_LEAD_ROUTING_OPTIONS,
299
225
  campaignIds: [
300
226
  ...named,
301
227
  ...unnamed
302
- ]
228
+ ],
229
+ inCampaign: FORM_IN_CAMPAIGN_OPTIONS
303
230
  };
304
231
  }, [
305
232
  siteCampaigns.options,
306
- siteCampaigns.ready,
307
- filedCampaignIds
233
+ formClauses
308
234
  ]);
309
- const formFilter = usePagedRowsFilter({
310
- data: formWindowRows,
311
- rows: formRows,
312
- hasMore,
313
- page,
314
- setPage,
315
- pageSize,
316
- setPageSize
317
- }, {
318
- fields: FORM_FILTER_FIELDS,
319
- options: formFilterOptions,
320
- headers: FORM_FILTER_HEADERS,
321
- search: FORM_SEARCH_FIELDS,
322
- clauses: formClauses,
323
- onChange: setFormClauses
324
- });
325
- // The rows on screen: the page, or the page of the matches.
326
- const forms = formFilter.rows;
327
235
  /*
328
236
  * The COUNT is a server aggregate, not the length of a page. `forms` is one
329
237
  * page, and publishing that as the site's form count would read as room to
330
238
  * spare on a site that is already at the ceiling.
331
239
  */ const liveFormCount = useLiveArtifactCount(hostId, 'forms');
332
- // Pending or refused, the page window stands in: a LOWER bound, never a
333
- // confident zero.
334
- const formsUsed = liveFormCount != null ? liveFormCount : formWindow.length;
240
+ // Pending or refused, the page stands in: a LOWER bound, never a confident
241
+ // zero.
242
+ const formsUsed = liveFormCount != null ? liveFormCount : forms.length;
243
+ /*
244
+ * How many forms are retired, for the "keep their slot" line (AGL-2674).
245
+ * An aggregate, because the list no longer reads a retired row unless it is
246
+ * asked for one; asked again after this card retires or restores a form.
247
+ */ const [retiredCount, setRetiredCount] = useState(null);
248
+ useEffect(()=>{
249
+ let active = true;
250
+ getCountFromServer(query(formsCollection, where('retired', '==', true))).then((snapshot)=>{
251
+ if (active) setRetiredCount(snapshot.data().count);
252
+ }).catch(()=>{
253
+ // The line is a courtesy; without the count it is simply not shown.
254
+ });
255
+ return ()=>{
256
+ active = false;
257
+ };
258
+ }, [
259
+ formsCollection,
260
+ retiredEpoch
261
+ ]);
335
262
  /**
336
263
  * Name first, then create (AGL-700).
337
264
  *
@@ -437,12 +364,13 @@ const FORM_HIDDEN_COLUMNS = {
437
364
  align: 'right',
438
365
  headerAlign: 'right',
439
366
  /*
440
- * A dash rather than a `0` whenever the field is absent.
367
+ * A dash rather than a `0` whenever the field holds no number.
441
368
  *
442
- * `stats.leads` is incremented only for a form whose `routing.lead` is
443
- * set, so a form that does not route to leads has no recorded figure.
444
- * Rendering `0` there would read as "this form has produced no leads",
445
- * 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.
446
374
  */ valueGetter: (_value, row)=>{
447
375
  var _row_stats;
448
376
  return typeof (row == null ? void 0 : (_row_stats = row.stats) == null ? void 0 : _row_stats.leads) === 'number' ? row.stats.leads : null;
@@ -482,23 +410,37 @@ const FORM_HIDDEN_COLUMNS = {
482
410
  return (value == null ? void 0 : (_value_toLocaleString = value.toLocaleString) == null ? void 0 : _value_toLocaleString.call(value)) || '--';
483
411
  }
484
412
  },
413
+ /*
414
+ * Status and Lead routing, hidden until Columns shows them (AGL-3330).
415
+ * Each is a select the Filters panel offers either way, and draws the
416
+ * choice its value names. Status reads the FACT, `archivedAt`, rather
417
+ * than the `retired` mirror the query asks, so a form the backfill has
418
+ * not reached yet still reads true.
419
+ */ {
420
+ field: 'status',
421
+ headerName: 'Status',
422
+ minWidth: 110,
423
+ valueGetter: (_value, row)=>String(isFormArchived(row))
424
+ },
485
425
  {
486
- // Hidden until Columns shows it; filterable either way (AGL-3330).
487
- field: 'createdAt',
488
- headerName: 'Created',
489
- minWidth: 170,
490
- flex: 1,
491
- type: 'date',
492
- valueGetter: (value)=>{
493
- var _ref;
494
- var _value_toDate;
495
- return (_ref = value == null ? void 0 : (_value_toDate = value.toDate) == null ? void 0 : _value_toDate.call(value)) != null ? _ref : null;
496
- },
497
- valueFormatter: (value)=>{
498
- var _value_toLocaleString;
499
- return (value == null ? void 0 : (_value_toLocaleString = value.toLocaleString) == null ? void 0 : _value_toLocaleString.call(value)) || '--';
426
+ field: 'leadRouting',
427
+ headerName: 'Lead routing',
428
+ minWidth: 120,
429
+ valueGetter: (_value, row)=>{
430
+ var _row_routing;
431
+ return String((row == null ? void 0 : (_row_routing = row.routing) == null ? void 0 : _row_routing.lead) === true);
500
432
  }
501
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
+ },
502
444
  listActionsColumn((row)=>{
503
445
  var _form_displayName;
504
446
  const form = _extends({}, row, {
@@ -613,7 +555,7 @@ const FORM_HIDDEN_COLUMNS = {
613
555
  limit: Aglyn.checkQuota(org, 'formsPerHost', formsUsed).limit,
614
556
  noun: "form"
615
557
  }),
616
- formWindow.some((form)=>isFormArchived(form)) ? /*#__PURE__*/ _jsx(Typography, {
558
+ retiredCount ? /*#__PURE__*/ _jsx(Typography, {
617
559
  variant: "caption",
618
560
  color: "text.secondary",
619
561
  children: 'Retired forms keep their slot'
@@ -656,21 +598,43 @@ const FORM_HIDDEN_COLUMNS = {
656
598
  },
657
599
  children: retireError
658
600
  }) : null,
659
- /*#__PURE__*/ _jsx(ListFilterChips, _extends({}, formFilter.chipsProps)),
660
- formFilter.filtering && hasMore ? /*#__PURE__*/ _jsx(Typography, {
661
- variant: "caption",
662
- color: "text.secondary",
663
- children: `Filtering the ${formFilter.read} forms read so far — the next page reads more.`
664
- }) : null,
601
+ /*#__PURE__*/ _jsx(ListFilterChips, {
602
+ fields: FORM_LIST_FILTER_FIELDS,
603
+ headers: FORM_LIST_FILTER_HEADERS,
604
+ clauses: gridFilter.clauses,
605
+ onChange: gridFilter.setClauses,
606
+ options: formFilterOptions
607
+ }),
608
+ /*#__PURE__*/ _jsx(ListQueryNotices, {
609
+ refused: listQueryRefusals(plan.refused, {
610
+ fields: FORM_LIST_FILTER_FIELDS,
611
+ headers: FORM_LIST_FILTER_HEADERS,
612
+ options: formFilterOptions
613
+ }),
614
+ notices: plan.notices
615
+ }),
665
616
  /*#__PURE__*/ _jsx(ListTable, _extends({
666
617
  rowHeight: TABLE_ROW_HEIGHT,
667
- columns: formFilter.filterColumns(columns),
618
+ columns: listFilterGridColumns(columns, FORM_LIST_FILTER_FIELDS, formFilterOptions, FORM_LIST_FILTER_HEADERS),
668
619
  initialState: {
669
620
  columns: {
670
621
  columnVisibilityModel: FORM_HIDDEN_COLUMNS
671
622
  }
623
+ },
624
+ /*
625
+ * The grid must NOT also filter. The query answers the panel and
626
+ * the search, so a second pass in the browser could only drop rows
627
+ * that already matched — it compares what a column DRAWS, and the
628
+ * query compares what the document stores.
629
+ */ filterMode: "server",
630
+ filterModel: gridFilter.filterModel,
631
+ onFilterModelChange: gridFilter.onFilterModelChange,
632
+ quickFilter: true,
633
+ // The Campaign filter's names are read once the panel is opened.
634
+ onPreferencePanelOpen: (params)=>{
635
+ if (params.openedPanelValue === 'filters') setWantsCampaigns(true);
672
636
  }
673
- }, formFilter.gridProps, formFilter.filtering ? {
637
+ }, filtering ? {
674
638
  noRowsLabel: 'No forms match these filters'
675
639
  } : {
676
640
  noRowsLabel: 'No forms yet',
@@ -689,9 +653,19 @@ const FORM_HIDDEN_COLUMNS = {
689
653
  // rather than "these are on their way".
690
654
  loading: status === 'loading',
691
655
  // Paged by the footer below, so the grid must not also slice.
692
- 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
693
660
  })),
694
- /*#__PURE__*/ _jsx(ListPagination, _extends({}, formFilter.pagination)),
661
+ /*#__PURE__*/ _jsx(ListPagination, {
662
+ page: page,
663
+ pageSize: pageSize,
664
+ rowCount: forms.length,
665
+ hasMore: hasMore,
666
+ onPageChange: setPage,
667
+ onPageSizeChange: setPageSize
668
+ }),
695
669
  /*#__PURE__*/ _jsx(CreateArtifactDrawer, {
696
670
  open: createOpen,
697
671
  onClose: ()=>setCreateOpen(false),