@aglyn/plugins-data 1.0.0-beta.221 → 1.0.0-beta.223

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +9 -6
  2. package/package.json +10 -10
  3. package/src/index.d.ts +1 -1
  4. package/src/index.js +1 -1
  5. package/src/index.js.map +1 -1
  6. package/src/lib/artifact/dataset-schema-artifact.server.d.ts +57 -0
  7. package/src/lib/artifact/dataset-schema-artifact.server.js +308 -0
  8. package/src/lib/artifact/dataset-schema-artifact.server.js.map +1 -0
  9. package/src/lib/artifact/dataset-schema.d.ts +136 -0
  10. package/src/lib/artifact/dataset-schema.js +232 -0
  11. package/src/lib/artifact/dataset-schema.js.map +1 -0
  12. package/src/lib/components/dataset-record-dialog.component.d.ts +1 -1
  13. package/src/lib/components/dataset-record-dialog.component.js +1 -1
  14. package/src/lib/components/dataset-record-dialog.component.js.map +1 -1
  15. package/src/lib/components/dataset-record-filter.d.ts +1 -1
  16. package/src/lib/components/dataset-record-filter.js +2 -2
  17. package/src/lib/components/dataset-record-filter.js.map +1 -1
  18. package/src/lib/components/dataset-schema-dialog.component.d.ts +1 -1
  19. package/src/lib/components/dataset-schema-dialog.component.js +55 -23
  20. package/src/lib/components/dataset-schema-dialog.component.js.map +1 -1
  21. package/src/lib/components/host-datasets-card.component.js +91 -403
  22. package/src/lib/components/host-datasets-card.component.js.map +1 -1
  23. package/src/lib/constants/bundle-common.d.ts +12 -0
  24. package/src/lib/constants/bundle-common.js +13 -0
  25. package/src/lib/constants/bundle-common.js.map +1 -1
  26. package/src/lib/declarations.console-server.d.ts +16 -1
  27. package/src/lib/declarations.console-server.js +34 -2
  28. package/src/lib/declarations.console-server.js.map +1 -1
  29. package/src/lib/declarations.server.d.ts +13 -0
  30. package/src/lib/declarations.server.js +48 -2
  31. package/src/lib/declarations.server.js.map +1 -1
  32. package/src/lib/form-target/dataset-form-record-target.server.js +5 -3
  33. package/src/lib/form-target/dataset-form-record-target.server.js.map +1 -1
  34. package/src/lib/model/dataset-models.d.ts +392 -0
  35. package/src/lib/model/dataset-models.js +806 -0
  36. package/src/lib/model/dataset-models.js.map +1 -0
  37. package/src/lib/model/dataset-record-view.d.ts +1 -1
  38. package/src/lib/model/dataset-record-view.js +1 -2
  39. package/src/lib/model/dataset-record-view.js.map +1 -1
  40. package/src/lib/model/dataset-record.js +2 -2
  41. package/src/lib/model/dataset-record.js.map +1 -1
  42. package/src/lib/model/datasets.d.ts +162 -0
  43. package/src/lib/model/datasets.js +227 -0
  44. package/src/lib/model/datasets.js.map +1 -0
  45. package/src/lib/plugin.d.ts +4 -0
  46. package/src/lib/plugin.js +40 -0
  47. package/src/lib/plugin.js.map +1 -1
  48. package/src/lib/record-pages/record-page-live-paths.server.d.ts +55 -0
  49. package/src/lib/record-pages/record-page-live-paths.server.js +83 -0
  50. package/src/lib/record-pages/record-page-live-paths.server.js.map +1 -0
  51. package/src/lib/record-pages/record-page-read.server.d.ts +70 -0
  52. package/src/lib/record-pages/record-page-read.server.js +175 -0
  53. package/src/lib/record-pages/record-page-read.server.js.map +1 -0
  54. package/src/lib/record-pages/record-page-resolver.server.d.ts +37 -0
  55. package/src/lib/record-pages/record-page-resolver.server.js +131 -0
  56. package/src/lib/record-pages/record-page-resolver.server.js.map +1 -0
  57. package/src/lib/record-pages/record-page-sitemap.server.d.ts +32 -0
  58. package/src/lib/record-pages/record-page-sitemap.server.js +102 -0
  59. package/src/lib/record-pages/record-page-sitemap.server.js.map +1 -0
  60. package/src/lib/record-pages/record-page-source.d.ts +42 -0
  61. package/src/lib/record-pages/record-page-source.js +176 -0
  62. package/src/lib/record-pages/record-page-source.js.map +1 -0
  63. package/src/lib/record-pages/record-pages-route.d.ts +42 -0
  64. package/src/lib/record-pages/record-pages-route.js +348 -0
  65. package/src/lib/record-pages/record-pages-route.js.map +1 -0
  66. package/src/lib/record-pages/record-pages.d.ts +218 -0
  67. package/src/lib/record-pages/record-pages.js +351 -0
  68. package/src/lib/record-pages/record-pages.js.map +1 -0
  69. package/src/lib/record-pages/record-template-row-chip.component.d.ts +28 -0
  70. package/src/lib/record-pages/record-template-row-chip.component.js +87 -0
  71. package/src/lib/record-pages/record-template-row-chip.component.js.map +1 -0
  72. package/src/lib/record-pages/record-template-section.component.d.ts +30 -0
  73. package/src/lib/record-pages/record-template-section.component.js +491 -0
  74. package/src/lib/record-pages/record-template-section.component.js.map +1 -0
  75. package/src/lib/repeat/dataset-repeat-rows.d.ts +2 -1
  76. package/src/lib/repeat/dataset-repeat-rows.js +30 -12
  77. package/src/lib/repeat/dataset-repeat-rows.js.map +1 -1
  78. package/src/lib/repeat/dataset-repeat-rows.server.d.ts +1 -1
  79. package/src/lib/repeat/dataset-repeat-rows.server.js +23 -12
  80. package/src/lib/repeat/dataset-repeat-rows.server.js.map +1 -1
  81. package/src/lib/repeat/repeat-record-pages.d.ts +48 -0
  82. package/src/lib/repeat/repeat-record-pages.js +64 -0
  83. package/src/lib/repeat/repeat-record-pages.js.map +1 -0
  84. package/src/lib/server/announce-dataset-records.d.ts +1 -1
  85. package/src/lib/server/announce-dataset-records.js +1 -1
  86. package/src/lib/server/announce-dataset-records.js.map +1 -1
  87. package/src/lib/server/api-v1/datasets.js +5 -3
  88. package/src/lib/server/api-v1/datasets.js.map +1 -1
  89. package/src/lib/server/dataset-figures.js +2 -2
  90. package/src/lib/server/dataset-figures.js.map +1 -1
  91. package/src/lib/server/dataset-live-pages.d.ts +147 -0
  92. package/src/lib/server/dataset-live-pages.js +436 -0
  93. package/src/lib/server/dataset-live-pages.js.map +1 -0
  94. package/src/lib/server/dataset-steps.server.d.ts +45 -0
  95. package/src/lib/server/dataset-steps.server.js +268 -0
  96. package/src/lib/server/dataset-steps.server.js.map +1 -0
  97. package/src/lib/server/datasets-route.d.ts +2 -0
  98. package/src/lib/server/datasets-route.js +102 -6
  99. package/src/lib/server/datasets-route.js.map +1 -1
  100. package/src/lib/server/resolve-dataset.d.ts +40 -0
  101. package/src/lib/server/resolve-dataset.js +64 -0
  102. package/src/lib/server/resolve-dataset.js.map +1 -0
  103. package/src/lib/server.d.ts +3 -2
  104. package/src/lib/server.js +8 -9
  105. package/src/lib/server.js.map +1 -1
  106. package/src/lib/site-bundle/datasets-package.d.ts +42 -0
  107. package/src/lib/site-bundle/datasets-package.js +95 -0
  108. package/src/lib/site-bundle/datasets-package.js.map +1 -0
  109. package/src/lib/site-bundle/datasets-site-bundle.server.js +2 -2
  110. package/src/lib/site-bundle/datasets-site-bundle.server.js.map +1 -1
  111. package/src/lib/{model/index.js → tenant-api.d.ts} +12 -7
  112. package/src/lib/tenant-api.js +30 -0
  113. package/src/lib/tenant-api.js.map +1 -0
  114. package/src/lib/transfer/dataset-transfer-key.d.ts +30 -0
  115. package/src/lib/transfer/dataset-transfer-key.js +31 -0
  116. package/src/lib/transfer/dataset-transfer-key.js.map +1 -0
  117. package/src/lib/transfer/dataset-transfer-model.d.ts +146 -0
  118. package/src/lib/transfer/dataset-transfer-model.js +536 -0
  119. package/src/lib/transfer/dataset-transfer-model.js.map +1 -0
  120. package/src/lib/{model/index.d.ts → transfer/dataset-transfer-resource.d.ts} +2 -6
  121. package/src/lib/transfer/dataset-transfer-resource.js +41 -0
  122. package/src/lib/transfer/dataset-transfer-resource.js.map +1 -0
  123. package/src/lib/transfer/dataset-transfer.server.d.ts +69 -0
  124. package/src/lib/transfer/dataset-transfer.server.js +930 -0
  125. package/src/lib/transfer/dataset-transfer.server.js.map +1 -0
  126. package/src/lib/model/dataset-csv.d.ts +0 -36
  127. package/src/lib/model/dataset-csv.js +0 -85
  128. package/src/lib/model/dataset-csv.js.map +0 -1
  129. package/src/lib/model/dataset-io.d.ts +0 -44
  130. package/src/lib/model/dataset-io.js +0 -85
  131. package/src/lib/model/dataset-io.js.map +0 -1
  132. package/src/lib/model/index.js.map +0 -1
  133. package/src/lib/server/datasets-export-route.d.ts +0 -57
  134. package/src/lib/server/datasets-export-route.js +0 -229
  135. package/src/lib/server/datasets-export-route.js.map +0 -1
@@ -16,8 +16,8 @@
16
16
  */ 'use client';
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
- import { checkDatasetQuota, checkEntitlement, checkQuota, coerceDocumentValues, datasetIntegrityUpdate, datasetValueToInput, effectiveDatasetModel, formatDatasetValue, getCustomFieldType, modelFromFieldEntries, parseDatasetFieldEntries, pluginDocsHelp, validateDocument } from "@aglyn/aglyn";
20
- import { exportShortfall, mapImportColumns, parseImportRows } from "../model/index.js";
19
+ import { TransferResumeImport } from "@aglyn/aglyn/app-utils/transfer-resume-import";
20
+ import { checkDatasetQuota, checkEntitlement, checkQuota, getCustomFieldType, pluginDocsHelp, useTransferLauncher } from "@aglyn/aglyn";
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";
@@ -29,7 +29,7 @@ import { useListGridFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-grid-filt
29
29
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
30
30
  import { Timestamp } from "@aglyn/shared-util-timestamp";
31
31
  import { Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Stack, TextField, Typography } from "@mui/material";
32
- import { collection, deleteDoc, deleteField, doc, documentId, getCountFromServer, getDocs, limit, orderBy, query, where, setDoc, writeBatch } from "firebase/firestore";
32
+ import { collection, deleteDoc, deleteField, doc, getCountFromServer, getDocs, limit, query, where, setDoc, writeBatch } from "firebase/firestore";
33
33
  import { useCallback, useEffect, useMemo, useState } from "react";
34
34
  import { useFirestore, useFirestoreCollection, useHostActivityLogger, useOrgDataScope, usePagedCollection, useScopeTokens, useUser } from "@aglyn/tenant-feature-instance";
35
35
  import { listQueryConstraints } from "@aglyn/tenant-feature-instance/hooks/use-list-query";
@@ -37,19 +37,10 @@ import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
37
37
  import { DatasetSchemaDialog } from "./dataset-schema-dialog.component.js";
38
38
  import { DatasetRecordDialog } from "./dataset-record-dialog.component.js";
39
39
  import { datasetRecordFilter, planRecordQuery, recordColumn } from "./dataset-record-filter.js";
40
- /**
41
- * How many existing records a keyed CSV import may consult.
42
- *
43
- * The upsert has to ask "does a record with this key already exist", which no
44
- * page can answer — so it reads its own window, once, when a key field is
45
- * chosen. The window is bounded because `recordsPerDataset` is unlimited on
46
- * the agency plan and an unbounded client read is a browser hang behind a
47
- * dialog button; it is the same ceiling the listener used to carry, moved from
48
- * every mount of the card to the one moment it is needed.
49
- *
50
- * A dataset larger than this is not silently mis-imported: the import says
51
- * which keys it could consult, measured against the server's exact count.
52
- */ const IMPORT_KEY_WINDOW = 500;
40
+ import { coerceDocumentValues, datasetIntegrityUpdate, datasetValueToInput, effectiveDatasetModel, formatDatasetValue, modelFromFieldEntries, validateDocument } from "../model/dataset-models.js";
41
+ import { fillRecordAddresses } from "../record-pages/record-pages.js";
42
+ import { datasetDisplayName, parseDatasetFieldEntries } from "../model/datasets.js";
43
+ import { datasetTransferResourceKey } from "../transfer/dataset-transfer-key.js";
53
44
  /**
54
45
  * The sentence that says who a NEW dataset is shared with, for the card and
55
46
  * for the site Data page's banner above it.
@@ -68,7 +59,7 @@ import { datasetRecordFilter, planRecordQuery, recordColumn } from "./dataset-re
68
59
  * dark-launch rule as everywhere (no plan, no gate). The host path is
69
60
  * the pre-migration fallback for hosts not yet org-wired.
70
61
  */ export function HostDatasetsCard(props) {
71
- var _datasets_find, _ref, _ref1, _ref2, _ref3, _ref4, _ref5;
62
+ var _datasets_find, _ref, _ref1, _ref2, _ref3;
72
63
  const { hostId } = props;
73
64
  // `dataScope` is null until the async org lookup settles (AGL-1061), and
74
65
  // for a host with no owning org — there is no host fallback to act on
@@ -195,10 +186,7 @@ import { datasetRecordFilter, planRecordQuery, recordColumn } from "./dataset-re
195
186
  });
196
187
  const datasets = useMemo(()=>[
197
188
  ...datasetDocs != null ? datasetDocs : []
198
- ].sort((a, b)=>{
199
- var _a_displayName, _b_displayName;
200
- return String((_a_displayName = a.displayName) != null ? _a_displayName : '').localeCompare(String((_b_displayName = b.displayName) != null ? _b_displayName : ''));
201
- }), [
189
+ ].sort((a, b)=>datasetDisplayName(a).localeCompare(datasetDisplayName(b))), [
202
190
  datasetDocs
203
191
  ]);
204
192
  const [selectedId, setSelectedId] = useState(null);
@@ -507,10 +495,8 @@ import { datasetRecordFilter, planRecordQuery, recordColumn } from "./dataset-re
507
495
  // takes the same API-only route (AGL-1050) — the host-scope client
508
496
  // write that used to stand in for it is gone.
509
497
  if (!a || !b || !orgId) return;
510
- const fieldFor = (target, fieldId)=>{
511
- var _target_displayName;
512
- return {
513
- name: String((_target_displayName = target.displayName) != null ? _target_displayName : fieldId),
498
+ const fieldFor = (target, fieldId)=>({
499
+ name: datasetDisplayName(target) || fieldId,
514
500
  type: 'reference',
515
501
  required: true,
516
502
  reference: {
@@ -518,10 +504,9 @@ import { datasetRecordFilter, planRecordQuery, recordColumn } from "./dataset-re
518
504
  displayFieldId: effectiveDatasetModel(target).order[0],
519
505
  onDelete: 'setNull'
520
506
  }
521
- };
522
- };
507
+ });
523
508
  const payload = {
524
- displayName: `${a.displayName} ↔ ${b.displayName}`,
509
+ displayName: `${datasetDisplayName(a)} ↔ ${datasetDisplayName(b)}`,
525
510
  fields: [
526
511
  'aRef',
527
512
  'bRef'
@@ -570,7 +555,7 @@ import { datasetRecordFilter, planRecordQuery, recordColumn } from "./dataset-re
570
555
  if (!selected) return;
571
556
  const confirmed = await confirm({
572
557
  title: 'Delete this collection?',
573
- description: `"${selected.displayName}"` + // What is actually being destroyed, not what is loaded (AGL-1716):
558
+ description: `"${datasetDisplayName(selected)}"` + // What is actually being destroyed, not what is loaded (AGL-1716):
574
559
  // a 40,000-row dataset used to warn about "its 500 documents".
575
560
  (recordCount ? ` and its ${recordCount} document${recordCount === 1 ? '' : 's'}` : '') + ' stop resolving in repeatable components and bindings that ' + 'reference it.',
576
561
  confirmationText: 'Delete',
@@ -619,7 +604,7 @@ import { datasetRecordFilter, planRecordQuery, recordColumn } from "./dataset-re
619
604
  logActivity('Deleted dataset', {
620
605
  type: 'content',
621
606
  id: selected.$id,
622
- name: selected.displayName
607
+ name: datasetDisplayName(selected)
623
608
  });
624
609
  }, [
625
610
  selected,
@@ -765,7 +750,9 @@ import { datasetRecordFilter, planRecordQuery, recordColumn } from "./dataset-re
765
750
  ]);
766
751
  const handleSaveRecord = useCallback(async ()=>{
767
752
  if (!editor || !selected || !dataScope) return;
768
- const coerced = coerceDocumentValues(model, editor.values);
753
+ // The editor holds the record's WHOLE values, so an address it already
754
+ // has stays and an empty one fills in from its source (AGL-3475).
755
+ const coerced = fillRecordAddresses(model, coerceDocumentValues(model, editor.values));
769
756
  const errors = validateDocument(model, coerced);
770
757
  if (Object.keys(errors).length) {
771
758
  return void setEditor((prev)=>prev ? _extends({}, prev, {
@@ -821,7 +808,7 @@ import { datasetRecordFilter, planRecordQuery, recordColumn } from "./dataset-re
821
808
  logActivity(editor.id ? 'Updated record' : 'Added record', {
822
809
  type: 'content',
823
810
  id: selected.$id,
824
- name: selected.displayName
811
+ name: datasetDisplayName(selected)
825
812
  });
826
813
  }, [
827
814
  editor,
@@ -880,7 +867,7 @@ import { datasetRecordFilter, planRecordQuery, recordColumn } from "./dataset-re
880
867
  try {
881
868
  snapshot = await getDocs(query(collection(firestore, dataScope[0], dataScope[1], 'datasets', other.$id, 'records'), where('referencedIds', 'array-contains', record.$id)));
882
869
  } catch (unused) {
883
- return void enqueueSnackbar(`Cannot delete: "${other.displayName}" could not be checked for ` + 'references, so removing this record could break it. Nothing ' + 'was deleted.', {
870
+ return void enqueueSnackbar(`Cannot delete: "${datasetDisplayName(other)}" could not be checked for ` + 'references, so removing this record could break it. Nothing ' + 'was deleted.', {
884
871
  variant: 'error'
885
872
  });
886
873
  }
@@ -895,7 +882,7 @@ import { datasetRecordFilter, planRecordQuery, recordColumn } from "./dataset-re
895
882
  return ((_otherModel_fields_fieldId = otherModel.fields[fieldId]) == null ? void 0 : (_otherModel_fields_fieldId_reference = _otherModel_fields_fieldId.reference) == null ? void 0 : _otherModel_fields_fieldId_reference.onDelete) === 'restrict';
896
883
  });
897
884
  if (restricted) {
898
- return void enqueueSnackbar(`Cannot delete: referenced by ${hits.length} document` + `${hits.length === 1 ? '' : 's'} in "${other.displayName}"`, {
885
+ return void enqueueSnackbar(`Cannot delete: referenced by ${hits.length} document` + `${hits.length === 1 ? '' : 's'} in "${datasetDisplayName(other)}"`, {
899
886
  variant: 'warning',
900
887
  persist: false
901
888
  });
@@ -946,276 +933,69 @@ import { datasetRecordFilter, planRecordQuery, recordColumn } from "./dataset-re
946
933
  announceRecords,
947
934
  enqueueSnackbar
948
935
  ]);
949
- // CSV/JSON round-tripping (AGL-182).
950
- const download = useCallback((name, mime, text)=>{
951
- const url = URL.createObjectURL(new Blob([
952
- text
953
- ], {
954
- type: mime
955
- }));
956
- const anchor = document.createElement('a');
957
- anchor.href = url;
958
- anchor.download = name;
959
- anchor.click();
960
- URL.revokeObjectURL(url);
961
- }, []);
962
- const [exporting, setExporting] = useState(null);
963
936
  /**
964
- * The export is a SERVER stream now (AGL-2335).
965
- *
966
- * It used to serialize `records` — the card's live listener window, which
967
- * is `limit(500)` with no `orderBy`. Firestore answers an unordered limit
968
- * in document-id order over auto-ids, so a 2,000-row dataset did not
969
- * export "the first 500": it exported 500 unpredictable rows, and running
970
- * it twice could produce different ones. `sortDatasetRecords` then sorted
971
- * that sample, so the file looked ordered and was arbitrary. The button
972
- * said `CSV`, and the agency guide calls the result a *full handover*.
973
- *
974
- * Fetching the whole collection here instead was not an option — the
975
- * agency plan's `recordsPerDataset` is UNLIMITED, so an unbounded
976
- * client-side read is a browser hang and a large read bill behind a button
977
- * labelled `CSV`. `/api/orgs/datasets/export` pages and streams it.
978
- *
979
- * The response is CHECKED, not trusted: the route reports a `count()`
980
- * aggregate in `X-Aglyn-Export-Rows`, and a body with fewer rows than that
981
- * is refused rather than saved. A stream that dies halfway produces a
982
- * perfectly well-formed shorter file — nothing about the bytes says they
983
- * are short, which is precisely how the old truncation stayed invisible.
984
- */ const handleExport = useCallback((format)=>async ()=>{
985
- if (!(selected == null ? void 0 : selected.$id) || !orgId || exporting) return;
986
- setExporting(format);
987
- try {
988
- var _selected_displayName;
989
- const response = await authorizedFetch(user, `/api/orgs/datasets/export?orgId=${encodeURIComponent(orgId)}` + `&datasetId=${encodeURIComponent(selected.$id)}` + `&format=${format}`);
990
- if (!response.ok) {
991
- var _ref;
992
- const failure = await response.json().catch(()=>({}));
993
- throw new Error((_ref = failure == null ? void 0 : failure.error) != null ? _ref : 'Export failed');
994
- }
995
- const text = await response.text();
996
- const { promised, received, short } = exportShortfall(response.headers.get('X-Aglyn-Export-Rows'), text, format);
997
- if (short) {
998
- enqueueSnackbar(`Export incomplete — ${received} of ${promised} documents ` + 'arrived. Nothing was saved; try again.', {
999
- variant: 'error'
1000
- });
1001
- return;
1002
- }
1003
- const base = String((_selected_displayName = selected.displayName) != null ? _selected_displayName : 'collection').replace(/[^A-Za-z0-9_-]+/g, '-').toLowerCase() || 'collection';
1004
- download(`${base}.${format}`, format === 'csv' ? 'text/csv' : 'application/json', text);
1005
- } catch (error) {
1006
- enqueueSnackbar(error instanceof Error ? error.message : 'Export failed', {
1007
- variant: 'error'
937
+ * Import and export (AGL-3530) open the console's import wizard and export
938
+ * dialog on this dataset (`data.dataset:<datasetId>`): the file is read,
939
+ * matched and written on the server, through the same record writes as
940
+ * every other create and update, and the export streams the fields the
941
+ * person picks. Outside the console shell there is no launcher, and no
942
+ * Import or Export to offer. Inside it, Import is offered to a member who
943
+ * may import (Manage data) and Export to any member who can see the
944
+ * dataset (AGL-3546) — the transfer routes' own rule.
945
+ */ const transfer = useTransferLauncher();
946
+ const transferTarget = (selected == null ? void 0 : selected.$id) ? {
947
+ resource: datasetTransferResourceKey(selected.$id),
948
+ scope: 'org'
949
+ } : null;
950
+ const canImport = Boolean(transfer && transferTarget && transfer.can('import', transferTarget));
951
+ const canExport = Boolean(transfer && transferTarget && transfer.can('export', transferTarget));
952
+ const handleImport = useCallback(()=>{
953
+ if (!transfer || !(selected == null ? void 0 : selected.$id)) return;
954
+ const dataset = {
955
+ id: selected.$id,
956
+ name: datasetDisplayName(selected)
957
+ };
958
+ transfer.openImport({
959
+ resource: datasetTransferResourceKey(dataset.id),
960
+ scope: 'org',
961
+ title: dataset.name ? `Import into ${dataset.name}` : 'Import records',
962
+ onFinished: ()=>{
963
+ // An import moves the record count, and the aggregate is a one-shot.
964
+ setRecordCountEpoch((epoch)=>epoch + 1);
965
+ logActivity('Imported records', {
966
+ type: 'content',
967
+ id: dataset.id,
968
+ name: dataset.name
1008
969
  });
1009
- } finally{
1010
- setExporting(null);
1011
- }
1012
- }, [
1013
- selected,
1014
- orgId,
1015
- user,
1016
- exporting,
1017
- download,
1018
- enqueueSnackbar
1019
- ]);
1020
- // keyField (wave v6): optional unique key — matching rows update the
1021
- // existing record instead of appending a duplicate.
1022
- const [importer, setImporter] = useState(null);
1023
- const importPreview = useMemo(()=>{
1024
- if (!importer) return null;
1025
- const rows = parseImportRows(importer.text);
1026
- if (!rows || !rows.length) return null;
1027
- const { mapping, unmatched } = mapImportColumns(model, Object.keys(rows[0]));
1028
- const prepared = rows.map((raw)=>{
1029
- const input = {};
1030
- for (const [column, fieldId] of Object.entries(mapping)){
1031
- var _raw_column;
1032
- input[fieldId] = (_raw_column = raw[column]) != null ? _raw_column : '';
1033
970
  }
1034
- const values = coerceDocumentValues(model, input);
1035
- const errors = validateDocument(model, values);
1036
- return {
1037
- values,
1038
- errors,
1039
- valid: !Object.keys(errors).length
1040
- };
1041
971
  });
1042
- return {
1043
- prepared,
1044
- unmatched,
1045
- valid: prepared.filter((row)=>row.valid).length
1046
- };
1047
972
  }, [
1048
- importer,
1049
- model
973
+ transfer,
974
+ selected,
975
+ logActivity
1050
976
  ]);
1051
- const handleImport = useCallback(async ()=>{
1052
- var _ref, _quota_limit;
1053
- if (!selected || !importPreview || !dataScope) return;
1054
- const validRows = importPreview.prepared.filter((row)=>row.valid);
1055
- // Unique-key upsert (wave v6): with a key field picked, incoming rows
1056
- // that match an existing record's key update it in place, and
1057
- // duplicate keys within the import collapse to the last row.
1058
- const keyField = (_ref = importer == null ? void 0 : importer.keyField) != null ? _ref : '';
1059
- const keyOf = (values)=>{
1060
- var _ref;
1061
- return String((_ref = values == null ? void 0 : values[keyField]) != null ? _ref : '').trim().toLowerCase();
1062
- };
1063
- let updates = [];
1064
- let creates = validRows;
1065
- if (keyField) {
1066
- var _ref1;
1067
- /*
1068
- * The key index is its OWN read, not the table's window.
1069
- *
1070
- * It used to be built from the card's live listener, which is now a
1071
- * PAGE — and a page cannot answer "does this key already exist". An
1072
- * upsert that consults ten rows would turn nearly every update into a
1073
- * create: duplicate rows, and quota spent on them.
1074
- *
1075
- * So it is read here, once, at the moment a key field is actually
1076
- * chosen — which is also strictly cheaper than the listener it replaces,
1077
- * since that one paid for five hundred documents on every mount of a
1078
- * card nobody had scrolled.
1079
- *
1080
- * ⚠️ STILL BOUNDED, and the bound is honest rather than hidden. An
1081
- * unbounded client read is what AGL-2335 refused for the export, for the
1082
- * same reason: `recordsPerDataset` is unlimited on the agency plan, so
1083
- * "read them all" is a browser hang behind a dialog button. Past the
1084
- * ceiling the reader is TOLD which keys could be consulted instead of
1085
- * being handed silent duplicates — `recordCount` is the server
1086
- * aggregate, so that comparison is exact.
1087
- */ const existingByKey = new Map();
1088
- const keyWindow = await getDocs(query(collection(firestore, dataScope[0], dataScope[1], 'datasets', selected.$id, 'records'), orderBy(documentId()), limit(IMPORT_KEY_WINDOW))).catch(()=>null);
1089
- for (const snapshot of (_ref1 = keyWindow == null ? void 0 : keyWindow.docs) != null ? _ref1 : []){
1090
- var _snapshot_get;
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
- });
1097
- }
1098
- if (recordCount > IMPORT_KEY_WINDOW) {
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.', {
1100
- variant: 'warning',
1101
- persist: true
1102
- });
1103
- }
1104
- const deduped = new Map();
1105
- const keyless = [];
1106
- for (const row of validRows){
1107
- const key = keyOf(row.values);
1108
- if (key) deduped.set(key, row);
1109
- else keyless.push(row);
1110
- }
1111
- updates = [];
1112
- creates = [
1113
- ...keyless
1114
- ];
1115
- for (const [key, row] of deduped){
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);
1126
- }
1127
- }
1128
- // Against the dataset's real size, not the loaded window (AGL-1716) —
1129
- // `room` subtracts this number from the limit, so an understated count
1130
- // did not merely fail to refuse, it INFLATED the slots offered.
1131
- const quota = checkQuota(org, 'recordsPerDataset', recordCount + creates.length - 1);
1132
- const room = quota.allowed ? creates.length : Math.max(0, Number((_quota_limit = quota.limit) != null ? _quota_limit : 0) - recordCount);
1133
- const toWrite = creates.slice(0, room);
1134
- if (!toWrite.length && !updates.length) {
1135
- return void enqueueSnackbar(`Record limit reached (${quota.limit}) — see Billing to upgrade`, {
1136
- variant: 'warning',
1137
- persist: false
1138
- });
1139
- }
1140
- // Updates stay client-direct (no quota consumed), chunked under
1141
- // Firestore's 500-writes/batch limit.
1142
- for(let start = 0; start < updates.length; start += 400){
1143
- const batch = writeBatch(firestore);
1144
- updates.slice(start, start + 400).forEach((update)=>{
1145
- const ref = doc(firestore, dataScope[0], dataScope[1], 'datasets', selected.$id, 'records', update.id);
1146
- batch.set(ref, _extends({
1147
- values: update.values
1148
- }, datasetIntegrityUpdate(model, update.values, deleteField()), {
1149
- updatedAt: Timestamp.now()
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
- ]
1160
- });
1161
- });
1162
- await batch.commit();
1163
- }
1164
- // Creates go through the quota-enforcing API (AGL-473). The legacy
1165
- // host-scope client batch that used to be the `else` here is gone
1166
- // (AGL-1050) — it was the largest of the quota bypasses, since a CSV
1167
- // import could create records by the hundred without ever asking.
1168
- try {
1169
- if (toWrite.length) {
1170
- await callDatasetApi({
1171
- action: 'import-records',
1172
- datasetId: selected.$id,
1173
- records: toWrite.map((row)=>({
1174
- values: row.values
1175
- }))
1176
- });
1177
- } else if (updates.length) {
1178
- // An import that only UPDATED existing rows never reaches the server
1179
- // leg, so it has to announce for itself — otherwise the one import
1180
- // shape that changes every row of a dataset is the one that refreshes
1181
- // nothing (AGL-3113).
1182
- await announceRecords(selected.$id);
977
+ const handleExport = useCallback(()=>{
978
+ if (!transfer || !(selected == null ? void 0 : selected.$id)) return;
979
+ const name = datasetDisplayName(selected);
980
+ transfer.openExport(_extends({
981
+ resource: datasetTransferResourceKey(selected.$id),
982
+ scope: 'org',
983
+ title: name ? `Export ${name}` : 'Export records'
984
+ }, filteringRecords ? {
985
+ filter: {
986
+ label: 'The records the table’s filters and search show',
987
+ value: {
988
+ clauses: recordClauses,
989
+ search: gridFilter.searchWords
990
+ }
1183
991
  }
1184
- } catch (error) {
1185
- var _ref2;
1186
- return void enqueueSnackbar((_ref2 = error == null ? void 0 : error.message) != null ? _ref2 : 'Import failed', {
1187
- variant: 'warning',
1188
- persist: false
1189
- });
1190
- }
1191
- setImporter(null);
1192
- // An import moves the record count and the aggregate is a one-shot —
1193
- // the listener refreshes the ROWS for free, the count has to be asked
1194
- // again or the next import prices its room off a pre-import number.
1195
- setRecordCountEpoch((epoch)=>epoch + 1);
1196
- const skippedInvalid = importPreview.prepared.length - validRows.length;
1197
- const skippedQuota = creates.length - toWrite.length;
1198
- enqueueSnackbar(`Imported ${toWrite.length} new` + (updates.length ? `, updated ${updates.length}` : '') + (skippedInvalid ? `, ${skippedInvalid} invalid skipped` : '') + (skippedQuota ? `, ${skippedQuota} over the record limit` : ''), {
1199
- variant: skippedInvalid || skippedQuota ? 'warning' : 'success'
1200
- });
1201
- logActivity('Imported records', {
1202
- type: 'content',
1203
- id: selected.$id,
1204
- name: selected.displayName
1205
- });
992
+ } : {}));
1206
993
  }, [
994
+ transfer,
1207
995
  selected,
1208
- importPreview,
1209
- importer == null ? void 0 : importer.keyField,
1210
- org,
1211
- model,
1212
- recordCount,
1213
- firestore,
1214
- dataScope,
1215
- callDatasetApi,
1216
- announceRecords,
1217
- enqueueSnackbar,
1218
- logActivity
996
+ filteringRecords,
997
+ recordClauses,
998
+ gridFilter.searchWords
1219
999
  ]);
1220
1000
  /*
1221
1001
  * One column per schema field, filterable as far as `datasetRecordFilter`
@@ -1315,7 +1095,7 @@ import { datasetRecordFilter, planRecordQuery, recordColumn } from "./dataset-re
1315
1095
  },
1316
1096
  children: datasets.map((item)=>/*#__PURE__*/ _jsx(MenuItem, {
1317
1097
  value: item.$id,
1318
- children: item.displayName
1098
+ children: datasetDisplayName(item)
1319
1099
  }, item.$id))
1320
1100
  }),
1321
1101
  /*#__PURE__*/ _jsx(Button, {
@@ -1329,29 +1109,22 @@ import { datasetRecordFilter, planRecordQuery, recordColumn } from "./dataset-re
1329
1109
  onClick: ()=>setSchemaOpen(true),
1330
1110
  children: 'Schema'
1331
1111
  }),
1332
- /*#__PURE__*/ _jsx(Button, {
1112
+ transferTarget ? /*#__PURE__*/ _jsx(TransferResumeImport, _extends({
1113
+ target: transferTarget
1114
+ }, selected ? {
1115
+ title: `Import into ${datasetDisplayName(selected)}`
1116
+ } : {}, {
1117
+ onFinished: ()=>setRecordCountEpoch((epoch)=>epoch + 1)
1118
+ })) : null,
1119
+ canImport ? /*#__PURE__*/ _jsx(Button, {
1333
1120
  size: "small",
1334
- onClick: ()=>setImporter({
1335
- text: '',
1336
- keyField: ''
1337
- }),
1121
+ onClick: handleImport,
1338
1122
  children: 'Import'
1339
- }),
1340
- recordCount || records.length ? /*#__PURE__*/ _jsxs(_Fragment, {
1341
- children: [
1342
- /*#__PURE__*/ _jsx(Button, {
1343
- size: "small",
1344
- disabled: Boolean(exporting),
1345
- onClick: handleExport('csv'),
1346
- children: exporting === 'csv' ? 'Exporting…' : 'CSV'
1347
- }),
1348
- /*#__PURE__*/ _jsx(Button, {
1349
- size: "small",
1350
- disabled: Boolean(exporting),
1351
- onClick: handleExport('json'),
1352
- children: exporting === 'json' ? 'Exporting…' : 'JSON'
1353
- })
1354
- ]
1123
+ }) : null,
1124
+ canExport && (recordCount || records.length) ? /*#__PURE__*/ _jsx(Button, {
1125
+ size: "small",
1126
+ onClick: handleExport,
1127
+ children: 'Export'
1355
1128
  }) : null,
1356
1129
  /*#__PURE__*/ _jsx(Button, {
1357
1130
  size: "small",
@@ -1676,91 +1449,6 @@ import { datasetRecordFilter, planRecordQuery, recordColumn } from "./dataset-re
1676
1449
  })
1677
1450
  ]
1678
1451
  }),
1679
- /*#__PURE__*/ _jsxs(Dialog, {
1680
- open: Boolean(importer),
1681
- onClose: ()=>setImporter(null),
1682
- maxWidth: "sm",
1683
- fullWidth: true,
1684
- children: [
1685
- /*#__PURE__*/ _jsx(DialogTitle, {
1686
- children: 'Import records'
1687
- }),
1688
- /*#__PURE__*/ _jsxs(DialogContent, {
1689
- sx: {
1690
- display: 'flex',
1691
- flexDirection: 'column',
1692
- gap: 2
1693
- },
1694
- children: [
1695
- /*#__PURE__*/ _jsx(TextField, {
1696
- multiline: true,
1697
- minRows: 6,
1698
- size: "small",
1699
- label: "CSV (with header row) or JSON array",
1700
- value: (_ref4 = importer == null ? void 0 : importer.text) != null ? _ref4 : '',
1701
- onChange: (event)=>setImporter((prev)=>prev ? _extends({}, prev, {
1702
- text: event.target.value
1703
- }) : prev),
1704
- sx: {
1705
- mt: 1
1706
- },
1707
- helperText: "Columns match by field id or display name"
1708
- }),
1709
- /*#__PURE__*/ _jsxs(TextField, {
1710
- select: true,
1711
- size: "small",
1712
- label: "Match on field (upsert)",
1713
- value: (_ref5 = importer == null ? void 0 : importer.keyField) != null ? _ref5 : '',
1714
- onChange: (event)=>setImporter((prev)=>prev ? _extends({}, prev, {
1715
- keyField: event.target.value
1716
- }) : prev),
1717
- helperText: 'Rows whose value matches an existing record update it ' + 'instead of appending a duplicate.',
1718
- sx: {
1719
- maxWidth: 280
1720
- },
1721
- children: [
1722
- /*#__PURE__*/ _jsx(MenuItem, {
1723
- value: "",
1724
- children: 'None — always append'
1725
- }),
1726
- model.order.map((fieldId)=>{
1727
- var _ref;
1728
- var _model_fields_fieldId;
1729
- return /*#__PURE__*/ _jsx(MenuItem, {
1730
- value: fieldId,
1731
- children: (_ref = (_model_fields_fieldId = model.fields[fieldId]) == null ? void 0 : _model_fields_fieldId.name) != null ? _ref : fieldId
1732
- }, fieldId);
1733
- })
1734
- ]
1735
- }),
1736
- importPreview ? /*#__PURE__*/ _jsx(Typography, {
1737
- variant: "body2",
1738
- color: "text.secondary",
1739
- children: `${importPreview.prepared.length} rows parsed · ` + `${importPreview.valid} valid` + (importPreview.prepared.length - importPreview.valid ? ` · ${importPreview.prepared.length - importPreview.valid} invalid (skipped)` : '') + (importPreview.unmatched.length ? ` · unmatched columns: ${importPreview.unmatched.join(', ')}` : '')
1740
- }) : (importer == null ? void 0 : importer.text.trim()) ? /*#__PURE__*/ _jsx(Typography, {
1741
- variant: "body2",
1742
- color: "warning.main",
1743
- children: 'Nothing parseable yet — CSV needs a header row.'
1744
- }) : null
1745
- ]
1746
- }),
1747
- /*#__PURE__*/ _jsxs(DialogActions, {
1748
- children: [
1749
- /*#__PURE__*/ _jsx(Button, {
1750
- onClick: ()=>setImporter(null),
1751
- children: 'Cancel'
1752
- }),
1753
- /*#__PURE__*/ _jsx(Button, {
1754
- variant: "contained",
1755
- color: "primary",
1756
- disabled: !(importPreview == null ? void 0 : importPreview.valid),
1757
- onClick: handleImport,
1758
- children: 'Import valid rows'
1759
- })
1760
- ]
1761
- })
1762
- ]
1763
- }),
1764
1452
  /*#__PURE__*/ _jsxs(Dialog, {
1765
1453
  open: Boolean(joiner),
1766
1454
  onClose: ()=>setJoiner(null),
@@ -1800,7 +1488,7 @@ import { datasetRecordFilter, planRecordQuery, recordColumn } from "./dataset-re
1800
1488
  }) : prev),
1801
1489
  children: datasets.map((item)=>/*#__PURE__*/ _jsx(MenuItem, {
1802
1490
  value: item.$id,
1803
- children: item.displayName
1491
+ children: datasetDisplayName(item)
1804
1492
  }, item.$id))
1805
1493
  }, side);
1806
1494
  })