@aglyn/plugins-data 1.0.0-beta.198 → 1.0.0-beta.200

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,6 +21,7 @@ import { exportShortfall, mapImportColumns, parseImportRows } from "../model/ind
21
21
  import { CardDisplay, useConfirmationContext } from "@aglyn/shared-ui-jsx";
22
22
  import ListFilterChips from "@aglyn/shared-ui-jsx/components/list-filter-chips.component";
23
23
  import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
24
+ import ListQueryNotices, { listQueryRefusals } from "@aglyn/shared-ui-jsx/components/list-query-notices.component";
24
25
  import { ListTable, listActionsColumn } from "@aglyn/shared-ui-jsx/components/list-table.component";
25
26
  import QuotaReadoutComponent from "@aglyn/shared-ui-jsx/components/quota-readout.component";
26
27
  import { listFilterGridColumns } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
@@ -31,10 +32,11 @@ import { Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, St
31
32
  import { collection, deleteDoc, deleteField, doc, documentId, getCountFromServer, getDocs, limit, orderBy, query, where, setDoc, writeBatch } from "firebase/firestore";
32
33
  import { useCallback, useEffect, useMemo, useState } from "react";
33
34
  import { useFirestore, useFirestoreCollection, useHostActivityLogger, useOrgDataScope, usePagedCollection, useScopeTokens, useUser } from "@aglyn/tenant-feature-instance";
35
+ import { listQueryConstraints } from "@aglyn/tenant-feature-instance/hooks/use-list-query";
34
36
  import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
35
37
  import { DatasetSchemaDialog } from "./dataset-schema-dialog.component.js";
36
38
  import { DatasetRecordDialog } from "./dataset-record-dialog.component.js";
37
- import { datasetRecordFilter, matchRecordWindow, planRecordFilter, RECORD_FILTER_WINDOW, recordColumn } from "./dataset-record-filter.js";
39
+ import { datasetRecordFilter, planRecordQuery, recordColumn } from "./dataset-record-filter.js";
38
40
  /**
39
41
  * How many existing records a keyed CSV import may consult.
40
42
  *
@@ -66,7 +68,7 @@ import { datasetRecordFilter, matchRecordWindow, planRecordFilter, RECORD_FILTER
66
68
  * dark-launch rule as everywhere (no plan, no gate). The host path is
67
69
  * the pre-migration fallback for hosts not yet org-wired.
68
70
  */ export function HostDatasetsCard(props) {
69
- var _datasets_find, _ref, _ref1, _ref2, _ref3, _ref4, _ref5, _ref6;
71
+ var _datasets_find, _ref, _ref1, _ref2, _ref3, _ref4, _ref5;
70
72
  const { hostId } = props;
71
73
  // `dataScope` is null until the async org lookup settles (AGL-1061), and
72
74
  // for a host with no owning org — there is no host fallback to act on
@@ -264,24 +266,23 @@ import { datasetRecordFilter, matchRecordWindow, planRecordFilter, RECORD_FILTER
264
266
  * rows would run in one order within a page and another across pages.
265
267
  * The grid's column sort is off for the same reason.
266
268
  *
267
- * ## Filtering, and what reaches every record
269
+ * ## Filtering: every clause and the search word are on the query
268
270
  *
269
- * The grid's Filters panel and quick search narrow the walk through the
270
- * record's `filterKeys` (`datasetFilterKeys`): a token array Firestore's
271
- * automatic single-field index serves with `array-contains` beside the same
272
- * document-name order, so it needs no composite index and no index
273
- * deploy. ONE condition is served that way — the first clause a token can
274
- * answer (`datasetFilterToken`), else the first search word — and when it is
275
- * the only condition, the walk pages through every matching record exactly
276
- * as it pages through all of them.
271
+ * The grid's Filters panel and quick search are predicates on this same
272
+ * walk (AGL-3321), planned by `planRecordQuery` (`./dataset-record-filter`,
273
+ * over the shared `planListQuery`): equalities ask the record's
274
+ * `filterValues`, and one word-level clause — a text `contains`, a list
275
+ * member, or the search word — asks its `filterKeys`. Firestore's
276
+ * automatic single-field indexes merge them under the same document-name
277
+ * order, so every filter pages through every matching record exactly as
278
+ * the plain walk pages through all of them, with no composite index and
279
+ * no index deploy.
277
280
  *
278
- * Anything more — a second clause, a second word, a multi-word `contains`,
279
- * or a clause no token answers (number and date ranges, empty checks,
280
- * negations) — cannot be one `array-contains`. Those read a WINDOW of
281
- * {@link RECORD_FILTER_WINDOW} records under the served condition (or the
282
- * plain walk when none is servable), match the rest in memory, and page the
283
- * matches here; the caption says when the window was full. Ranges on
284
- * numbers and dates are therefore always window-only.
281
+ * Nothing is matched over the rows already loaded. What one query cannot
282
+ * hold is not offered (ranges, negations, empty checks) or is refused by
283
+ * name above the table (a second `contains`, a `contains` beside the
284
+ * search), and a many-word `contains` or search says it asked the first
285
+ * word.
285
286
  */ const gridFilter = useListGridFilter({
286
287
  selectFields: recordFilter.selectFields
287
288
  });
@@ -291,62 +292,32 @@ import { datasetRecordFilter, matchRecordWindow, planRecordFilter, RECORD_FILTER
291
292
  gridFilter.clauses,
292
293
  recordFilter.fields
293
294
  ]);
294
- const recordPlan = useMemo(()=>planRecordFilter(model, recordClauses, gridFilter.searchWords), [
295
+ const recordPlan = useMemo(()=>planRecordQuery(model, recordFilter, recordClauses, gridFilter.searchWords), [
295
296
  model,
297
+ recordFilter,
296
298
  recordClauses,
297
299
  gridFilter.searchWords
298
300
  ]);
299
- /** The walk, narrowed by the served token when there is one. */ const recordsQuery = useCallback((cap)=>dataScope && (selected == null ? void 0 : selected.$id) ? query(collection(firestore, dataScope[0], dataScope[1], 'datasets', selected.$id, 'records'), ...recordPlan.token ? [
300
- where('filterKeys', 'array-contains', recordPlan.token)
301
- ] : [], orderBy(documentId()), limit(cap)) : null, [
301
+ // The query's identity: a new plan is a new walk, from page one.
302
+ const recordPlanKey = JSON.stringify({
303
+ filters: recordPlan.plan.filters,
304
+ orderBy: recordPlan.plan.orderBy
305
+ });
306
+ const { rows: records, hasMore: hasMoreRecords, page: recordPage, setPage: setRecordPage, pageSize: recordPageSize, setPageSize: setRecordPageSize } = usePagedCollection((pageLimit)=>dataScope && (selected == null ? void 0 : selected.$id) ? query(collection(firestore, dataScope[0], dataScope[1], 'datasets', selected.$id, 'records'), ...listQueryConstraints(recordPlan.plan), limit(pageLimit)) : null, [
302
307
  firestore,
303
308
  dataScope,
304
309
  selected == null ? void 0 : selected.$id,
305
- recordPlan.token
306
- ]);
307
- const { rows: pagedRecords, hasMore: hasMorePaged, page: pagedPage, setPage: setPagedPage, pageSize: recordPageSize, setPageSize: setPagedPageSize } = usePagedCollection((pageLimit)=>recordPlan.windowed ? null : recordsQuery(pageLimit), [
308
- recordsQuery,
309
- recordPlan.windowed
310
+ recordPlanKey
310
311
  ], {
311
312
  idField: '$id'
312
313
  });
313
- // The window: one read past the cap, so "the window was full" is a fact.
314
- const { data: windowDocs } = useFirestoreCollection(()=>recordPlan.windowed ? recordsQuery(RECORD_FILTER_WINDOW + 1) : null, [
315
- recordsQuery,
316
- recordPlan.windowed
317
- ], {
318
- idField: '$id'
319
- });
320
- const windowRecords = useMemo(()=>recordPlan.windowed ? (windowDocs != null ? windowDocs : []).slice(0, RECORD_FILTER_WINDOW) : [], [
321
- recordPlan.windowed,
322
- windowDocs
323
- ]);
324
- const windowFull = recordPlan.windowed && ((_ref = windowDocs == null ? void 0 : windowDocs.length) != null ? _ref : 0) > RECORD_FILTER_WINDOW;
325
- const windowMatches = useMemo(()=>recordPlan.windowed ? matchRecordWindow(model, recordFilter.fields, windowRecords, recordPlan) : [], [
326
- recordPlan,
327
- model,
328
- recordFilter.fields,
329
- windowRecords
330
- ]);
331
- const [windowPage, setWindowPage] = useState(0);
332
- const [windowPageSize, setWindowPageSize] = useState(recordPageSize);
333
- useEffect(()=>{
334
- setWindowPage(0);
335
- }, [
336
- recordPlan,
337
- selected == null ? void 0 : selected.$id
338
- ]);
339
- /** Every record this card has read: the page, or the whole window. */ const records = recordPlan.windowed ? windowRecords : pagedRecords;
340
- /** The rows on screen. */ const shownRecords = recordPlan.windowed ? windowMatches.slice(windowPage * windowPageSize, (windowPage + 1) * windowPageSize) : pagedRecords;
341
- const recordPage = recordPlan.windowed ? windowPage : pagedPage;
342
- const setRecordPage = recordPlan.windowed ? setWindowPage : setPagedPage;
343
- const hasMoreRecords = recordPlan.windowed ? (windowPage + 1) * windowPageSize < windowMatches.length : hasMorePaged;
344
- const setRecordPageSize = useCallback((next)=>{
345
- setPagedPageSize(next);
346
- setWindowPageSize(next);
347
- setWindowPage(0);
348
- }, [
349
- setPagedPageSize
314
+ const recordRefusals = useMemo(()=>listQueryRefusals(recordPlan.refused, {
315
+ fields: recordFilter.fields,
316
+ headers: recordFilter.headers,
317
+ options: recordFilter.options
318
+ }), [
319
+ recordPlan.refused,
320
+ recordFilter
350
321
  ]);
351
322
  const filteringRecords = recordClauses.length > 0 || gridFilter.searchWords.some((word)=>word.trim());
352
323
  /**
@@ -476,7 +447,7 @@ import { datasetRecordFilter, matchRecordWindow, planRecordFilter, RECORD_FILTER
476
447
  ]);
477
448
  // Entries are human names — "Roast preference" → stable id
478
449
  // `roast_preference` with the pretty name kept for headers (AGL-558).
479
- const creatorEntries = parseDatasetFieldEntries((_ref1 = creator == null ? void 0 : creator.fields) != null ? _ref1 : '');
450
+ const creatorEntries = parseDatasetFieldEntries((_ref = creator == null ? void 0 : creator.fields) != null ? _ref : '');
480
451
  const creatorFields = creatorEntries.map((entry)=>entry.id);
481
452
  const handleCreate = useCallback(async ()=>{
482
453
  // No org, no create. The `else` that used to sit below this was the
@@ -816,6 +787,7 @@ import { datasetRecordFilter, matchRecordWindow, planRecordFilter, RECORD_FILTER
816
787
  'values',
817
788
  'referencedIds',
818
789
  'filterKeys',
790
+ 'filterValues',
819
791
  'updatedAt'
820
792
  ]
821
793
  });
@@ -1116,8 +1088,12 @@ import { datasetRecordFilter, matchRecordWindow, planRecordFilter, RECORD_FILTER
1116
1088
  const keyWindow = await getDocs(query(collection(firestore, dataScope[0], dataScope[1], 'datasets', selected.$id, 'records'), orderBy(documentId()), limit(IMPORT_KEY_WINDOW))).catch(()=>null);
1117
1089
  for (const snapshot of (_ref1 = keyWindow == null ? void 0 : keyWindow.docs) != null ? _ref1 : []){
1118
1090
  var _snapshot_get;
1119
- const key = keyOf((_snapshot_get = snapshot.get('values')) != null ? _snapshot_get : {});
1120
- if (key) existingByKey.set(key, snapshot.id);
1091
+ const values = (_snapshot_get = snapshot.get('values')) != null ? _snapshot_get : {};
1092
+ const key = keyOf(values);
1093
+ if (key) existingByKey.set(key, {
1094
+ id: snapshot.id,
1095
+ values
1096
+ });
1121
1097
  }
1122
1098
  if (recordCount > IMPORT_KEY_WINDOW) {
1123
1099
  enqueueSnackbar(`Matching on "${keyField}" read the first ` + `${IMPORT_KEY_WINDOW.toLocaleString()} of ${recordCount.toLocaleString()} ` + 'records — rows keyed beyond that will be added rather than updated.', {
@@ -1137,12 +1113,16 @@ import { datasetRecordFilter, matchRecordWindow, planRecordFilter, RECORD_FILTER
1137
1113
  ...keyless
1138
1114
  ];
1139
1115
  for (const [key, row] of deduped){
1140
- const existingId = existingByKey.get(key);
1141
- if (existingId) updates.push({
1142
- id: existingId,
1143
- values: row.values
1144
- });
1145
- else creates.push(row);
1116
+ const existing = existingByKey.get(key);
1117
+ // The import's columns over the record's own: a column the file
1118
+ // does not carry keeps its value, and the filter fields are derived
1119
+ // from the WHOLE record, not the columns imported.
1120
+ if (existing) {
1121
+ updates.push({
1122
+ id: existing.id,
1123
+ values: _extends({}, existing.values, row.values)
1124
+ });
1125
+ } else creates.push(row);
1146
1126
  }
1147
1127
  }
1148
1128
  // Against the dataset's real size, not the loaded window (AGL-1716) —
@@ -1167,8 +1147,16 @@ import { datasetRecordFilter, matchRecordWindow, planRecordFilter, RECORD_FILTER
1167
1147
  values: update.values
1168
1148
  }, datasetIntegrityUpdate(model, update.values, deleteField()), {
1169
1149
  updatedAt: Timestamp.now()
1170
- }), {
1171
- merge: true
1150
+ }), // Each named field replaced whole — `merge: true` would fold the
1151
+ // `filterValues` map into the stored one and keep a cleared value.
1152
+ {
1153
+ mergeFields: [
1154
+ 'values',
1155
+ 'referencedIds',
1156
+ 'filterKeys',
1157
+ 'filterValues',
1158
+ 'updatedAt'
1159
+ ]
1172
1160
  });
1173
1161
  });
1174
1162
  await batch.commit();
@@ -1320,7 +1308,7 @@ import { datasetRecordFilter, matchRecordWindow, planRecordFilter, RECORD_FILTER
1320
1308
  select: true,
1321
1309
  size: "small",
1322
1310
  label: "Dataset",
1323
- value: (_ref2 = selected == null ? void 0 : selected.$id) != null ? _ref2 : '',
1311
+ value: (_ref1 = selected == null ? void 0 : selected.$id) != null ? _ref1 : '',
1324
1312
  onChange: (event)=>setSelectedId(event.target.value),
1325
1313
  sx: {
1326
1314
  minWidth: 180
@@ -1386,21 +1374,19 @@ import { datasetRecordFilter, matchRecordWindow, planRecordFilter, RECORD_FILTER
1386
1374
  headers: recordFilter.headers,
1387
1375
  clauses: recordClauses,
1388
1376
  onChange: gridFilter.setClauses,
1389
- options: recordFilter.options,
1390
- servedField: recordPlan.windowed ? null : recordPlan.servedField
1377
+ options: recordFilter.options
1378
+ }),
1379
+ /*#__PURE__*/ _jsx(ListQueryNotices, {
1380
+ refused: recordRefusals,
1381
+ notices: recordPlan.notices
1391
1382
  }),
1392
- windowFull ? /*#__PURE__*/ _jsx(Typography, {
1393
- variant: "caption",
1394
- color: "text.secondary",
1395
- children: `Filtering the first ${RECORD_FILTER_WINDOW.toLocaleString()} records` + (recordPlan.token ? ' that match the first condition' : '') + ' — one filter or one search word on its own reaches every record.'
1396
- }) : null,
1397
1383
  /*#__PURE__*/ _jsx(ListTable, {
1398
1384
  "aria-label": "Records",
1399
- rows: shownRecords,
1385
+ rows: records,
1400
1386
  columns: recordGridColumns,
1401
1387
  onOpen: (id)=>setViewerId(id),
1402
- // One page of the walk (or of the window's matches), turned by
1403
- // the footer below: the grid neither slices nor filters it.
1388
+ // One page of the walk, turned by the footer below: the grid
1389
+ // neither slices nor filters it.
1404
1390
  hideFooter: true,
1405
1391
  filterMode: "server",
1406
1392
  filterModel: gridFilter.filterModel,
@@ -1412,7 +1398,7 @@ import { datasetRecordFilter, matchRecordWindow, planRecordFilter, RECORD_FILTER
1412
1398
  /*#__PURE__*/ _jsx(ListPagination, {
1413
1399
  page: recordPage,
1414
1400
  pageSize: recordPageSize,
1415
- rowCount: shownRecords.length,
1401
+ rowCount: records.length,
1416
1402
  hasMore: hasMoreRecords,
1417
1403
  onPageChange: setRecordPage,
1418
1404
  onPageSizeChange: setRecordPageSize
@@ -1464,7 +1450,7 @@ import { datasetRecordFilter, matchRecordWindow, planRecordFilter, RECORD_FILTER
1464
1450
  children: [
1465
1451
  /*#__PURE__*/ _jsx(TextField, {
1466
1452
  label: "Name",
1467
- value: (_ref3 = creator == null ? void 0 : creator.name) != null ? _ref3 : '',
1453
+ value: (_ref2 = creator == null ? void 0 : creator.name) != null ? _ref2 : '',
1468
1454
  onChange: (event)=>setCreator((prev)=>prev ? _extends({}, prev, {
1469
1455
  name: event.target.value
1470
1456
  }) : prev),
@@ -1477,7 +1463,7 @@ import { datasetRecordFilter, matchRecordWindow, planRecordFilter, RECORD_FILTER
1477
1463
  }),
1478
1464
  /*#__PURE__*/ _jsx(TextField, {
1479
1465
  label: "Fields",
1480
- value: (_ref4 = creator == null ? void 0 : creator.fields) != null ? _ref4 : '',
1466
+ value: (_ref3 = creator == null ? void 0 : creator.fields) != null ? _ref3 : '',
1481
1467
  onChange: (event)=>setCreator((prev)=>prev ? _extends({}, prev, {
1482
1468
  fields: event.target.value
1483
1469
  }) : prev),
@@ -1711,7 +1697,7 @@ import { datasetRecordFilter, matchRecordWindow, planRecordFilter, RECORD_FILTER
1711
1697
  minRows: 6,
1712
1698
  size: "small",
1713
1699
  label: "CSV (with header row) or JSON array",
1714
- value: (_ref5 = importer == null ? void 0 : importer.text) != null ? _ref5 : '',
1700
+ value: (_ref4 = importer == null ? void 0 : importer.text) != null ? _ref4 : '',
1715
1701
  onChange: (event)=>setImporter((prev)=>prev ? _extends({}, prev, {
1716
1702
  text: event.target.value
1717
1703
  }) : prev),
@@ -1724,7 +1710,7 @@ import { datasetRecordFilter, matchRecordWindow, planRecordFilter, RECORD_FILTER
1724
1710
  select: true,
1725
1711
  size: "small",
1726
1712
  label: "Match on field (upsert)",
1727
- value: (_ref6 = importer == null ? void 0 : importer.keyField) != null ? _ref6 : '',
1713
+ value: (_ref5 = importer == null ? void 0 : importer.keyField) != null ? _ref5 : '',
1728
1714
  onChange: (event)=>setImporter((prev)=>prev ? _extends({}, prev, {
1729
1715
  keyField: event.target.value
1730
1716
  }) : prev),