@aglyn/plugins-data 1.0.0-beta.143

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 (39) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/package.json +47 -0
  4. package/src/index.d.ts +19 -0
  5. package/src/index.js +20 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/data-console-page.d.ts +13 -0
  8. package/src/lib/components/data-console-page.js +63 -0
  9. package/src/lib/components/data-console-page.js.map +1 -0
  10. package/src/lib/components/dataset-record-dialog.component.d.ts +52 -0
  11. package/src/lib/components/dataset-record-dialog.component.js +249 -0
  12. package/src/lib/components/dataset-record-dialog.component.js.map +1 -0
  13. package/src/lib/components/dataset-schema-dialog.component.d.ts +51 -0
  14. package/src/lib/components/dataset-schema-dialog.component.js +979 -0
  15. package/src/lib/components/dataset-schema-dialog.component.js.map +1 -0
  16. package/src/lib/components/host-datasets-card.component.d.ts +39 -0
  17. package/src/lib/components/host-datasets-card.component.js +1831 -0
  18. package/src/lib/components/host-datasets-card.component.js.map +1 -0
  19. package/src/lib/constants/bundle-common.d.ts +8 -0
  20. package/src/lib/constants/bundle-common.js +9 -0
  21. package/src/lib/constants/bundle-common.js.map +1 -0
  22. package/src/lib/model/dataset-io.d.ts +43 -0
  23. package/src/lib/model/dataset-io.js +84 -0
  24. package/src/lib/model/dataset-io.js.map +1 -0
  25. package/src/lib/model/dataset-record-view.d.ts +157 -0
  26. package/src/lib/model/dataset-record-view.js +293 -0
  27. package/src/lib/model/dataset-record-view.js.map +1 -0
  28. package/src/lib/model/index.d.ts +22 -0
  29. package/src/lib/model/index.js +22 -0
  30. package/src/lib/model/index.js.map +1 -0
  31. package/src/lib/plugin.d.ts +35 -0
  32. package/src/lib/plugin.js +78 -0
  33. package/src/lib/plugin.js.map +1 -0
  34. package/src/lib/repeat/dataset-repeat-rows.d.ts +45 -0
  35. package/src/lib/repeat/dataset-repeat-rows.js +108 -0
  36. package/src/lib/repeat/dataset-repeat-rows.js.map +1 -0
  37. package/src/lib/repeat/dataset-repeat-source.d.ts +39 -0
  38. package/src/lib/repeat/dataset-repeat-source.js +95 -0
  39. package/src/lib/repeat/dataset-repeat-source.js.map +1 -0
@@ -0,0 +1,1831 @@
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 { _ as _extends } from "@swc/helpers/_/_extends";
18
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
19
+ import { applyDatasetQuery, checkDatasetQuota, checkEntitlement, checkQuota, coerceDocumentValues, datasetIntegrityUpdate, datasetValueToInput, effectiveDatasetModel, formatDatasetValue, getCustomFieldType, modelFromFieldEntries, parseDatasetFieldEntries, parseDatasetFilter, parseDatasetSort, pluginDocsHelp, validateDocument } from "@aglyn/aglyn";
20
+ import { exportShortfall, mapImportColumns, parseImportRows } from "../model/index.js";
21
+ import { CardDisplay, useConfirmationContext } from "@aglyn/shared-ui-jsx";
22
+ import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
23
+ import QuotaReadoutComponent from "@aglyn/shared-ui-jsx/components/quota-readout.component";
24
+ import { ScrollTable } from "@aglyn/shared-ui-jsx/components/scroll-table.component";
25
+ import { useSnackbar } from "@aglyn/shared-ui-snackstack";
26
+ import { Timestamp } from "@aglyn/shared-util-timestamp";
27
+ import { Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Stack, TableBody, TableCell, TableHead, TableRow, TextField, Tooltip, Typography } from "@mui/material";
28
+ import { collection, deleteDoc, deleteField, doc, documentId, getCountFromServer, getDocs, limit, orderBy, query, where, setDoc, writeBatch } from "firebase/firestore";
29
+ import { useCallback, useEffect, useMemo, useState } from "react";
30
+ import { useFirestore, useFirestoreCollection, useHostActivityLogger, useOrgDataScope, usePagedCollection, useScopeTokens, useUser } from "@aglyn/tenant-feature-instance";
31
+ import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
32
+ import { DatasetSchemaDialog } from "./dataset-schema-dialog.component.js";
33
+ import { DatasetRecordDialog } from "./dataset-record-dialog.component.js";
34
+ /**
35
+ * How many existing records a keyed CSV import may consult.
36
+ *
37
+ * The upsert has to ask "does a record with this key already exist", which no
38
+ * page can answer — so it reads its own window, once, when a key field is
39
+ * chosen. The window is bounded because `recordsPerDataset` is unlimited on
40
+ * the agency plan and an unbounded client read is a browser hang behind a
41
+ * dialog button; it is the same ceiling the listener used to carry, moved from
42
+ * every mount of the card to the one moment it is needed.
43
+ *
44
+ * A dataset larger than this is not silently mis-imported: the import says
45
+ * which keys it could consult, measured against the server's exact count.
46
+ */ const IMPORT_KEY_WINDOW = 500;
47
+ /**
48
+ * The sentence that says who a NEW dataset is shared with, for the card and
49
+ * for the site Data page's banner above it.
50
+ *
51
+ * `siteOnly` is true only on a site's Data page in an org whose Default
52
+ * sharing is "Only the site they were created in". Everywhere else a new
53
+ * dataset starts on All sites: the org Data page has no site to limit it to.
54
+ */ export function newDatasetSharingNote(siteOnly) {
55
+ return siteOnly ? 'Datasets belong to your organization. Your default sharing starts a ' + 'new one on this site only — use Schema to share it with more.' : 'Datasets belong to your organization. A new one is shared with ' + 'every site — use Schema to narrow that.';
56
+ }
57
+ /**
58
+ * Datasets editor (AGL-102): org-shared document collections at
59
+ * `orgs/{orgId}/datasets` (AGL-237/239) with a `records` subcollection,
60
+ * consumed by repeatable components (AGL-103) via `{{item.field}}`.
61
+ * Starter+ (`data-store` flag) with per-plan dataset and record caps —
62
+ * dark-launch rule as everywhere (no plan, no gate). The host path is
63
+ * the pre-migration fallback for hosts not yet org-wired.
64
+ */ export function HostDatasetsCard(props) {
65
+ var _datasets_find, _ref, _ref1, _ref2, _ref3, _ref4, _ref5;
66
+ const { hostId } = props;
67
+ // `dataScope` is null until the async org lookup settles (AGL-1061), and
68
+ // for a host with no owning org — there is no host fallback to act on
69
+ // any more (AGL-1050), so every path built from it is guarded rather
70
+ // than redirected.
71
+ const { scope: dataScope, orgId } = useOrgDataScope({
72
+ hostId,
73
+ orgId: props.orgId
74
+ });
75
+ // Scoped sharing (AGL-1044). Required, not cosmetic: under the AGL-1041
76
+ // rules a scoped member's UNFILTERED list is rejected outright —
77
+ // Firestore fails the whole query if any candidate would fail — so
78
+ // without this the Data page errors rather than showing fewer rows.
79
+ // Org-wide members get no filter: they read everything, and adding one
80
+ // would hide any dataset the AGL-1040 backfill missed.
81
+ const { tokens: scopeTokens, orgWide: viewerOrgWide, loaded: scopeLoaded } = useScopeTokens(orgId);
82
+ // Every dataset this card can now reach is an org dataset, and org
83
+ // datasets are all scoped (AGL-1041) — so the filter turns purely on
84
+ // whether the VIEWER is org-wide. It used to also ask whether an org had
85
+ // resolved, for the sake of unscoped rows on the host path.
86
+ const needsScope = !viewerOrgWide;
87
+ // Two answers must be in before listing: the org path (AGL-1061) and the
88
+ // viewer's scope tokens (AGL-1047). `useScopeTokens` reports org-wide
89
+ // while loading, so without `scopeLoaded` a scoped collaborator's first
90
+ // render sends an UNFILTERED list that the AGL-1041 rules deny per
91
+ // document. It recovers on the next render, which is exactly what makes
92
+ // it easy to miss: the page looks right and logs a denial every mount.
93
+ const scopeReady = Boolean(dataScope) && scopeLoaded;
94
+ const firestore = useFirestore();
95
+ const { enqueueSnackbar } = useSnackbar();
96
+ const { confirm } = useConfirmationContext();
97
+ const { org } = props;
98
+ const logActivity = useHostActivityLogger(hostId);
99
+ const { data: user } = useUser();
100
+ /**
101
+ * The site a dataset created here is created FROM, or undefined on the
102
+ * organization Data page.
103
+ *
104
+ * `/api/orgs/datasets` stamps a new dataset with the org's Default sharing,
105
+ * and that setting can only narrow to a site the request names — so a
106
+ * create that names none lands on All sites whatever the org chose. The
107
+ * explicit `orgId` is the org Data page's own scope and already wins over
108
+ * `hostId` when the org is resolved, so it wins here too: a create made
109
+ * there names no site, even if a caller hands the card both.
110
+ */ const createdFromHostId = props.orgId ? undefined : hostId || undefined;
111
+ const createdFrom = useMemo(()=>createdFromHostId ? {
112
+ hostId: createdFromHostId
113
+ } : {}, [
114
+ createdFromHostId
115
+ ]);
116
+ /**
117
+ * Whether a dataset created here starts on this site alone: the org chose
118
+ * "Only the site they were created in" and there is a site to limit it to.
119
+ * The caption reads it so it never promises All sites to a create that
120
+ * will not get it.
121
+ */ const createsSiteOnly = Boolean(createdFromHostId) && (org == null ? void 0 : org.defaultResourceScope) === 'host';
122
+ // Creates go through the console API (AGL-473): rules deny client-side
123
+ // `create` on org datasets/records so quotas are server-enforced.
124
+ // Updates/deletes stay client-direct (they don't consume quota). The
125
+ // legacy host scope (no org resolved) keeps the old client writes.
126
+ const callDatasetApi = useCallback(async (payload)=>{
127
+ const response = await authorizedFetch(user, '/api/orgs/datasets', {
128
+ method: 'POST',
129
+ headers: {
130
+ 'Content-Type': 'application/json'
131
+ },
132
+ body: JSON.stringify(_extends({
133
+ orgId
134
+ }, payload))
135
+ });
136
+ const result = await response.json().catch(()=>({}));
137
+ if (!response.ok) {
138
+ var _ref;
139
+ throw new Error((_ref = result == null ? void 0 : result.error) != null ? _ref : 'Dataset operation failed');
140
+ }
141
+ return result;
142
+ }, [
143
+ user,
144
+ orgId
145
+ ]);
146
+ /**
147
+ * Refresh the live pages showing a dataset after a write the BROWSER made
148
+ * (AGL-3113).
149
+ *
150
+ * Record edits and deletes are client-direct — they consume no quota, so
151
+ * AGL-473 left them out of the server leg — which also left them with
152
+ * nothing to announce from. They make a page listing the rows exactly as
153
+ * stale as a create does, so they take the same trip the create already
154
+ * takes, through the route that already holds the org membership check.
155
+ *
156
+ * BEST EFFORT. The record is stored by the time this runs, and the pages
157
+ * catch up on their own window regardless; a refused cache must never be
158
+ * shown to somebody as a refused save.
159
+ */ const announceRecords = useCallback(async (datasetId)=>{
160
+ try {
161
+ await callDatasetApi({
162
+ action: 'announce-records',
163
+ datasetId
164
+ });
165
+ } catch (error) {
166
+ console.error(error);
167
+ }
168
+ }, [
169
+ callDatasetApi
170
+ ]);
171
+ const { data: datasetDocs, status: datasetsStatus, /**
172
+ * The dataset rows are unconfirmed by the server (AGL-1358). The schema
173
+ * dialog is seeded from one of them and writes the whole `model` plus
174
+ * `visibleTo` back, so the freshness verdict belongs to THIS listener —
175
+ * the dialog has none of its own, which is why it is handed down rather
176
+ * than recomputed there.
177
+ */ fromCache: datasetsFromCache } = useFirestoreCollection(()=>// `scopeReady` already implies a non-null `dataScope`; testing it
178
+ // directly is what lets the compiler see that too.
179
+ dataScope && scopeReady ? query(collection(firestore, dataScope[0], dataScope[1], 'datasets'), ...needsScope ? [
180
+ where('visibleTo', 'array-contains-any', scopeTokens)
181
+ ] : [], limit(100)) : null, [
182
+ firestore,
183
+ dataScope,
184
+ needsScope,
185
+ scopeTokens,
186
+ scopeReady
187
+ ], {
188
+ idField: '$id'
189
+ });
190
+ const datasets = useMemo(()=>[
191
+ ...datasetDocs != null ? datasetDocs : []
192
+ ].sort((a, b)=>{
193
+ var _a_displayName, _b_displayName;
194
+ return String((_a_displayName = a.displayName) != null ? _a_displayName : '').localeCompare(String((_b_displayName = b.displayName) != null ? _b_displayName : ''));
195
+ }), [
196
+ datasetDocs
197
+ ]);
198
+ const [selectedId, setSelectedId] = useState(null);
199
+ const [schemaOpen, setSchemaOpen] = useState(false);
200
+ const selected = (_datasets_find = datasets.find((item)=>item.$id === selectedId)) != null ? _datasets_find : datasets[0];
201
+ // Typed model (AGL-179): drives headers, cell rendering, and the
202
+ // document form; v1 datasets get a derived all-text model.
203
+ const model = useMemo(()=>effectiveDatasetModel(selected != null ? selected : {}), [
204
+ selected
205
+ ]);
206
+ const fields = useMemo(()=>model.order, [
207
+ model
208
+ ]);
209
+ // Guarded on BOTH the scope and a real selection (AGL-1440): with no
210
+ // datasets, `selected` never resolves, and `datasets/-none-/records` is not
211
+ // an empty read for everyone — the AGL-1044 scoped-sharing rules deny it
212
+ // outright for a scoped collaborator, and a permanently denied listen is
213
+ // reopened forever by the refusal loop.
214
+ /**
215
+ * The records table PAGES (AGL-2501). It used to be `limit(500)` with no
216
+ * `orderBy`, every row rendered at once and no control anywhere.
217
+ *
218
+ * That was wrong twice. Five hundred documents were read and billed on
219
+ * every mount of a card nobody had scrolled — and past five hundred the
220
+ * remaining rows were not merely unrendered, they were UNREACHABLE, because
221
+ * nothing showed them and nothing asked for more. Firestore answers an
222
+ * unordered limit in document-id order over auto-ids, so which five hundred
223
+ * a reader got was arbitrary and could differ between two loads of the same
224
+ * dataset; `sortDatasetRecords` then sorted that sample by `order`, which
225
+ * made the table look ordered while being a sample. The export leg
226
+ * (AGL-2335) fixed exactly this shape for the file and left the table.
227
+ *
228
+ * ## Why the walk orders on the document id, and not on a field
229
+ *
230
+ * The usual fix is `orderBy` on the field the rows are sorted by. It cannot
231
+ * be used here: `orderBy` matches only documents that HAVE the field, so
232
+ * ordering on one that any writer omits does not mis-order the list, it
233
+ * hides rows from it — a worse failure than the one being fixed, and a
234
+ * silent one. Every candidate on this collection is omitted by some writer:
235
+ *
236
+ * * `order` — set by `/api/orgs/datasets` and by `POST /v1/datasets/{id}
237
+ * /records`, and NOT by the tenant form-submit leg or by the workflow
238
+ * `appendDataset`/`updateDataset` actions, which write `values` and
239
+ * `createdAt` only. Ordering on it would drop every row a public form or
240
+ * an automation ever produced — the rows a lead dataset is mostly made
241
+ * of.
242
+ * * `createdAt` — written by all four of those, and NOT by a site import:
243
+ * `IMPORTABLE_FIELDS.records` is `['values', 'order']`, so a restored
244
+ * record carries no `createdAt` at all.
245
+ * * `updatedAt` — the mirror image: the import stamps it, the form and
246
+ * automation legs do not.
247
+ *
248
+ * A document's NAME is not a field and cannot be absent, so ordering on it
249
+ * drops nothing and the walk is total: every record is reachable by paging,
250
+ * which is the property the old query lacked. It is not insertion order —
251
+ * ids are random — so the rows are not claimed to be in one. What they are
252
+ * is stable, complete, and the same on every load.
253
+ *
254
+ * `sortDatasetRecords` is deliberately NOT applied to the page. Re-sorting a
255
+ * window of an id-ordered walk by `order` is the same lie the old code told:
256
+ * rows would run in one order within a page and another across pages.
257
+ */ 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'), orderBy(documentId()), limit(pageLimit)) : null, [
258
+ firestore,
259
+ dataScope,
260
+ selected == null ? void 0 : selected.$id
261
+ ], {
262
+ idField: '$id'
263
+ });
264
+ /**
265
+ * The two HEAD-COUNTS on this card are server aggregates, not the lengths
266
+ * of the two capped listeners above (AGL-1716, the AGL-1706 shape).
267
+ *
268
+ * Both listeners are capped correctly — 100 datasets in a picker, 500 rows
269
+ * in a table, nobody needs more streamed in. What neither may do is answer
270
+ * "how many does this org have", and both did:
271
+ *
272
+ * * `records.length` saturated at 500 and was handed to
273
+ * `checkQuota(org, 'recordsPerDataset', …)`. The bands are 1,000 /
274
+ * 10,000 / 100,000 / 500,000 / 1,000,000, so on EVERY paid plan the
275
+ * check read 500-against-thousands and could never refuse. Worse, the
276
+ * importer computed its room as `limit − records.length`, so an
277
+ * understated count *inflated* the slots it offered.
278
+ * * `datasets.length` saturated at 100 and fed `checkDatasetQuota`, whose
279
+ * bands run to 250 / 500 / 2,000 above that window.
280
+ *
281
+ * `api/orgs/datasets` — the route that actually enforces both — counts with
282
+ * `collection('datasets').count()` and `collection('records').count()` on
283
+ * these exact paths, so this card was offering headroom the API would then
284
+ * refuse. It now asks the same question the same way, and the console
285
+ * billing page and the staff org page already read the dataset count with
286
+ * this identical unscoped aggregate.
287
+ *
288
+ * THE LISTS KEEP THEIR CAPS. The list and the count are different
289
+ * questions; only the count moved. A one-shot goes stale where a listener
290
+ * refreshed for free, so both are re-read after any mutation that can move
291
+ * them — the epochs below.
292
+ *
293
+ * No counting RULE moves: `checkQuota` and `checkDatasetQuota` are
294
+ * untouched entitlement inputs, and nothing here is metered by
295
+ * `report-usage` (it samples record counts server-side for storage, on its
296
+ * own read). Only this card's inputs stopped being saturated.
297
+ */ const [datasetCountEpoch, setDatasetCountEpoch] = useState(0);
298
+ const [recordCountEpoch, setRecordCountEpoch] = useState(0);
299
+ const [serverDatasetCount, setServerDatasetCount] = useState(null);
300
+ // TAGGED with the dataset it describes. A bare number would have to be
301
+ // cleared when the selection changes, and clearing state from inside the
302
+ // effect that fills it is a render loop waiting for one unstable dep;
303
+ // tagging answers "whose count is this?" without writing state on render.
304
+ const [serverRecordCount, setServerRecordCount] = useState(null);
305
+ useEffect(()=>{
306
+ if (!dataScope) return;
307
+ let active = true;
308
+ // Unscoped, exactly like `api/orgs/datasets` and the billing page: the
309
+ // quota is about the ORG's datasets, not the subset this collaborator
310
+ // can see. A scoped collaborator whom AGL-1044 denies the read falls
311
+ // through to the catch and keeps the visible-rows lower bound.
312
+ void getCountFromServer(collection(firestore, dataScope[0], dataScope[1], 'datasets')).then((snapshot)=>{
313
+ if (active) setServerDatasetCount(snapshot.data().count);
314
+ }).catch(()=>{
315
+ // Falls back to the listener length below — a LOWER bound, and this
316
+ // card's prior behaviour. Deliberately not 0: `checkDatasetQuota`
317
+ // answers from whatever it is handed, and 0 used is a confident
318
+ // wrong number in the flattering direction.
319
+ });
320
+ return ()=>{
321
+ active = false;
322
+ };
323
+ }, [
324
+ firestore,
325
+ dataScope,
326
+ datasetCountEpoch
327
+ ]);
328
+ // The dataset the counts below belong to. Distinct from `selectedId`
329
+ // above, which is the PICKER value and is null until the user chooses.
330
+ const countedDatasetId = selected == null ? void 0 : selected.$id;
331
+ useEffect(()=>{
332
+ if (!dataScope || !countedDatasetId) return;
333
+ let active = true;
334
+ void getCountFromServer(collection(firestore, dataScope[0], dataScope[1], 'datasets', countedDatasetId, 'records')).then((snapshot)=>{
335
+ if (active) {
336
+ setServerRecordCount({
337
+ datasetId: countedDatasetId,
338
+ count: snapshot.data().count
339
+ });
340
+ }
341
+ }).catch(()=>{
342
+ // Same fallback, same reason as above.
343
+ });
344
+ return ()=>{
345
+ active = false;
346
+ };
347
+ }, [
348
+ firestore,
349
+ dataScope,
350
+ countedDatasetId,
351
+ recordCountEpoch
352
+ ]);
353
+ // Pending, denied, or answered about the PREVIOUS selection, the loaded
354
+ // rows stand in. They can only UNDERSTATE (same collections, capped),
355
+ // never overstate, so nothing these figures gate fires on a count larger
356
+ // than the truth. Since AGL-2501 the record fallback is one PAGE, so it
357
+ // understates by more — which moves it further in the safe direction and
358
+ // changes nothing about which way it can be wrong.
359
+ const datasetCount = serverDatasetCount != null ? serverDatasetCount : datasets.length;
360
+ const recordCount = serverRecordCount && serverRecordCount.datasetId === countedDatasetId ? serverRecordCount.count : records.length;
361
+ // Query layer (AGL-181): the same evaluator the renderer uses, applied in
362
+ // memory over the rows in front of the reader. That window is now one PAGE
363
+ // rather than a 500-row sample (AGL-2501), which is a smaller claim and a
364
+ // true one — the helper text says which, because a filter box that looks
365
+ // like it searches the collection and does not is worse than one that says
366
+ // so.
367
+ const [filterText, setFilterText] = useState('');
368
+ const [sortText, setSortText] = useState('');
369
+ const visibleRecords = useMemo(()=>{
370
+ const where = parseDatasetFilter(filterText);
371
+ const orderBy = parseDatasetSort(sortText);
372
+ if (!where && !orderBy) return records;
373
+ const rows = records.map((record)=>{
374
+ var _record_values;
375
+ return _extends({
376
+ __record: record
377
+ }, (_record_values = record.values) != null ? _record_values : {});
378
+ });
379
+ return applyDatasetQuery(model, rows, _extends({}, where ? {
380
+ where: [
381
+ where
382
+ ]
383
+ } : {}, orderBy ? {
384
+ orderBy
385
+ } : {})).map((row)=>row.__record);
386
+ }, [
387
+ records,
388
+ filterText,
389
+ sortText,
390
+ model
391
+ ]);
392
+ // --- Create dataset -----------------------------------------------------
393
+ const [creator, setCreator] = useState(null);
394
+ const handleOpenCreator = useCallback(()=>{
395
+ if (!checkEntitlement(org, 'dataStore')) {
396
+ return enqueueSnackbar('Datasets require a Starter plan or higher — see Billing', {
397
+ variant: 'warning',
398
+ persist: false
399
+ });
400
+ }
401
+ // Addon-aware quota (AGL-132): purchased extra datasets raise the
402
+ // limit up to the plan's hard max; beyond that only an upgrade helps.
403
+ // The ORG's datasets, not the picker's window (AGL-1716).
404
+ const quota = checkDatasetQuota(org, datasetCount);
405
+ if (!quota.allowed) {
406
+ return enqueueSnackbar(quota.upgradeRequired ? `Dataset limit reached (${quota.limit}) — upgrade in Billing` : `Dataset limit reached (${quota.limit}) — add extra datasets ` + `for $${quota.addonPriceUsd}/mo each or upgrade in Billing`, {
407
+ variant: 'warning',
408
+ persist: false
409
+ });
410
+ }
411
+ setCreator({
412
+ name: '',
413
+ fields: ''
414
+ });
415
+ }, [
416
+ org,
417
+ datasetCount,
418
+ enqueueSnackbar
419
+ ]);
420
+ // Entries are human names — "Roast preference" → stable id
421
+ // `roast_preference` with the pretty name kept for headers (AGL-558).
422
+ const creatorEntries = parseDatasetFieldEntries((_ref = creator == null ? void 0 : creator.fields) != null ? _ref : '');
423
+ const creatorFields = creatorEntries.map((entry)=>entry.id);
424
+ const handleCreate = useCallback(async ()=>{
425
+ // No org, no create. The `else` that used to sit below this was the
426
+ // legacy host-scope client write (AGL-1050) — the one path by which a
427
+ // dataset could be created with NO quota check, since /api/orgs/datasets
428
+ // is where AGL-473 enforces it. The rules now deny that path outright;
429
+ // this is the client side of the same closure. The Add button is
430
+ // disabled without a scope, so reaching the guard means a stale closure.
431
+ if (!(creator == null ? void 0 : creator.name.trim()) || creatorFields.length === 0 || !orgId) return;
432
+ let id;
433
+ try {
434
+ const result = await callDatasetApi(_extends({
435
+ action: 'create-dataset',
436
+ displayName: creator.name.trim(),
437
+ fields: creatorFields,
438
+ // Typed model from day one (AGL-178); refine in the Schema dialog.
439
+ model: modelFromFieldEntries(creatorEntries)
440
+ }, createdFrom));
441
+ id = String(result.id);
442
+ } catch (error) {
443
+ var _ref;
444
+ return void enqueueSnackbar((_ref = error == null ? void 0 : error.message) != null ? _ref : 'Dataset create failed', {
445
+ variant: 'warning',
446
+ persist: false
447
+ });
448
+ }
449
+ setCreator(null);
450
+ setSelectedId(id);
451
+ setDatasetCountEpoch((epoch)=>epoch + 1);
452
+ enqueueSnackbar(`Dataset "${creator.name.trim()}" created`, {
453
+ variant: 'success',
454
+ persist: false
455
+ });
456
+ logActivity('Created dataset', {
457
+ type: 'content',
458
+ id,
459
+ name: creator.name.trim()
460
+ });
461
+ }, [
462
+ creator,
463
+ creatorEntries,
464
+ creatorFields,
465
+ orgId,
466
+ createdFrom,
467
+ callDatasetApi,
468
+ enqueueSnackbar,
469
+ logActivity
470
+ ]);
471
+ // Join collection template (AGL-180): extrinsic many-to-many as a
472
+ // visible, editable collection of FKey pairs — no magic.
473
+ const [joiner, setJoiner] = useState(null);
474
+ const handleCreateJoin = useCallback(async ()=>{
475
+ if (!(joiner == null ? void 0 : joiner.a) || !(joiner == null ? void 0 : joiner.b) || joiner.a === joiner.b) return;
476
+ const a = datasets.find((item)=>item.$id === joiner.a);
477
+ const b = datasets.find((item)=>item.$id === joiner.b);
478
+ // A join collection is a dataset and consumes the same quota, so it
479
+ // takes the same API-only route (AGL-1050) — the host-scope client
480
+ // write that used to stand in for it is gone.
481
+ if (!a || !b || !orgId) return;
482
+ const fieldFor = (target, fieldId)=>{
483
+ var _target_displayName;
484
+ return {
485
+ name: String((_target_displayName = target.displayName) != null ? _target_displayName : fieldId),
486
+ type: 'reference',
487
+ required: true,
488
+ reference: {
489
+ datasetId: target.$id,
490
+ displayFieldId: effectiveDatasetModel(target).order[0],
491
+ onDelete: 'setNull'
492
+ }
493
+ };
494
+ };
495
+ const payload = {
496
+ displayName: `${a.displayName} ↔ ${b.displayName}`,
497
+ fields: [
498
+ 'aRef',
499
+ 'bRef'
500
+ ],
501
+ model: {
502
+ order: [
503
+ 'aRef',
504
+ 'bRef'
505
+ ],
506
+ fields: {
507
+ aRef: fieldFor(a, 'aRef'),
508
+ bRef: fieldFor(b, 'bRef')
509
+ }
510
+ }
511
+ };
512
+ let id;
513
+ try {
514
+ const result = await callDatasetApi(_extends({
515
+ action: 'create-dataset'
516
+ }, payload, createdFrom));
517
+ id = String(result.id);
518
+ } catch (error) {
519
+ var _ref;
520
+ return void enqueueSnackbar((_ref = error == null ? void 0 : error.message) != null ? _ref : 'Join create failed', {
521
+ variant: 'warning',
522
+ persist: false
523
+ });
524
+ }
525
+ setJoiner(null);
526
+ setSelectedId(id);
527
+ // A join collection IS a dataset and consumes the same quota.
528
+ setDatasetCountEpoch((epoch)=>epoch + 1);
529
+ enqueueSnackbar('Join collection created', {
530
+ variant: 'success',
531
+ persist: false
532
+ });
533
+ }, [
534
+ joiner,
535
+ datasets,
536
+ orgId,
537
+ createdFrom,
538
+ callDatasetApi,
539
+ enqueueSnackbar
540
+ ]);
541
+ const handleDeleteDataset = useCallback(async ()=>{
542
+ if (!selected) return;
543
+ const confirmed = await confirm({
544
+ title: 'Delete this collection?',
545
+ description: `"${selected.displayName}"` + // What is actually being destroyed, not what is loaded (AGL-1716):
546
+ // a 40,000-row dataset used to warn about "its 500 documents".
547
+ (recordCount ? ` and its ${recordCount} document${recordCount === 1 ? '' : 's'}` : '') + ' stop resolving in repeatable components and bindings that ' + 'reference it.',
548
+ confirmationText: 'Delete',
549
+ confirmationButtonProps: {
550
+ color: 'error'
551
+ }
552
+ }).then(()=>true).catch(()=>false);
553
+ if (!confirmed || !dataScope) return;
554
+ // Deleting the dataset doc client-side would strand its `records`
555
+ // subcollection — Firestore doesn't cascade and only the Admin SDK has
556
+ // `recursiveDelete`, so the delete goes through the erase route
557
+ // (AGL-945). Single-record deletes below stay client-direct.
558
+ try {
559
+ var _ref;
560
+ const response = await authorizedFetch(user, '/api/resources/erase', {
561
+ method: 'POST',
562
+ headers: {
563
+ 'Content-Type': 'application/json'
564
+ },
565
+ body: JSON.stringify({
566
+ scope: dataScope[0],
567
+ scopeId: dataScope[1],
568
+ kind: 'datasets',
569
+ id: selected.$id,
570
+ // Names the site this delete was issued from, so the route can
571
+ // refuse to destroy a dataset shared beyond it (AGL-1046). The
572
+ // org Data page passes no host and keeps the org-wide delete.
573
+ hostId
574
+ })
575
+ });
576
+ const result = await response.json().catch(()=>({}));
577
+ if (!response.ok) throw new Error((_ref = result == null ? void 0 : result.error) != null ? _ref : 'Dataset delete failed');
578
+ } catch (error) {
579
+ var _ref1;
580
+ return void enqueueSnackbar((_ref1 = error == null ? void 0 : error.message) != null ? _ref1 : 'Dataset delete failed', {
581
+ variant: 'warning',
582
+ persist: false
583
+ });
584
+ }
585
+ setSelectedId(null);
586
+ setDatasetCountEpoch((epoch)=>epoch + 1);
587
+ enqueueSnackbar('Dataset deleted', {
588
+ variant: 'success',
589
+ persist: false
590
+ });
591
+ logActivity('Deleted dataset', {
592
+ type: 'content',
593
+ id: selected.$id,
594
+ name: selected.displayName
595
+ });
596
+ }, [
597
+ selected,
598
+ recordCount,
599
+ confirm,
600
+ dataScope,
601
+ hostId,
602
+ user,
603
+ enqueueSnackbar,
604
+ logActivity
605
+ ]);
606
+ // Reference pickers (AGL-180): load target-collection rows for every
607
+ // reference field in the model (id -> display label).
608
+ const [refOptions, setRefOptions] = useState({});
609
+ useEffect(()=>{
610
+ let active = true;
611
+ const referenceFields = model.order.filter((fieldId)=>{
612
+ var _model_fields_fieldId, _model_fields_fieldId_reference, _model_fields_fieldId1;
613
+ return ((_model_fields_fieldId = model.fields[fieldId]) == null ? void 0 : _model_fields_fieldId.type) === 'reference' && ((_model_fields_fieldId1 = model.fields[fieldId]) == null ? void 0 : (_model_fields_fieldId_reference = _model_fields_fieldId1.reference) == null ? void 0 : _model_fields_fieldId_reference.datasetId);
614
+ });
615
+ if (!referenceFields.length || !dataScope) {
616
+ setRefOptions({});
617
+ return;
618
+ }
619
+ void Promise.all(referenceFields.map(async (fieldId)=>{
620
+ var _reference_displayFieldId, _ref;
621
+ const reference = model.fields[fieldId].reference;
622
+ const target = datasets.find((item)=>item.$id === reference.datasetId);
623
+ const displayFieldId = (_reference_displayFieldId = reference.displayFieldId) != null ? _reference_displayFieldId : effectiveDatasetModel(target != null ? target : {}).order[0];
624
+ const snapshot = await getDocs(query(collection(firestore, dataScope[0], dataScope[1], 'datasets', reference.datasetId, 'records'), limit(200))).catch(()=>null);
625
+ const options = ((_ref = snapshot == null ? void 0 : snapshot.docs) != null ? _ref : []).map((docSnapshot)=>{
626
+ var _ref;
627
+ var _docSnapshot_get;
628
+ return {
629
+ id: docSnapshot.id,
630
+ label: String((_ref = (_docSnapshot_get = docSnapshot.get('values')) == null ? void 0 : _docSnapshot_get[displayFieldId != null ? displayFieldId : '']) != null ? _ref : docSnapshot.id)
631
+ };
632
+ });
633
+ return [
634
+ fieldId,
635
+ options
636
+ ];
637
+ })).then((entries)=>{
638
+ if (active) setRefOptions(Object.fromEntries(entries));
639
+ });
640
+ return ()=>{
641
+ active = false;
642
+ };
643
+ }, [
644
+ model,
645
+ datasets,
646
+ firestore,
647
+ dataScope
648
+ ]);
649
+ /**
650
+ * One reference target's label, or `null` when this ID resolves to nothing.
651
+ *
652
+ * The distinction the grid cannot draw. `referenceLabel` below falls back to
653
+ * the raw ID, which is the only thing a cell has room for and is
654
+ * indistinguishable from a resolved label that happens to look like an ID.
655
+ * The record view has room to say which, so it asks the question that has a
656
+ * `null` answer.
657
+ *
658
+ * Unresolvable covers more than a deleted target: `refOptions` loads at most
659
+ * 200 rows per referenced collection, so a live target outside that window
660
+ * reports the same way. Either way the ID is shown and neither is claimed to
661
+ * be a working link.
662
+ */ const resolveReferenceLabel = useCallback((fieldId, id)=>{
663
+ var _ref;
664
+ var _refOptions_fieldId_find, _refOptions_fieldId;
665
+ return (_ref = (_refOptions_fieldId = refOptions[fieldId]) == null ? void 0 : (_refOptions_fieldId_find = _refOptions_fieldId.find((option)=>option.id === id)) == null ? void 0 : _refOptions_fieldId_find.label) != null ? _ref : null;
666
+ }, [
667
+ refOptions
668
+ ]);
669
+ const referenceLabel = useCallback((fieldId, value)=>{
670
+ const ids = Array.isArray(value) ? value : value != null ? [
671
+ value
672
+ ] : [];
673
+ return ids.map((id)=>{
674
+ var _resolveReferenceLabel;
675
+ return (_resolveReferenceLabel = resolveReferenceLabel(fieldId, String(id))) != null ? _resolveReferenceLabel : String(id);
676
+ }).join(', ');
677
+ }, [
678
+ resolveReferenceLabel
679
+ ]);
680
+ /**
681
+ * The record the viewer is showing, held as an ID and resolved against the
682
+ * page the table already has.
683
+ *
684
+ * Holding the ID rather than the row is what keeps the view live without
685
+ * costing a read: the same listener that draws the table re-resolves it, so
686
+ * an edit landing underneath updates the open dialog. When the row leaves
687
+ * the page — deleted, or paged past — this answers `null` and the dialog
688
+ * closes, which is the honest response to a record no longer in front of
689
+ * this reader. Nothing here queries: `records` is the page query's answer.
690
+ */ const [viewerId, setViewerId] = useState(null);
691
+ const viewerRecord = useMemo(()=>{
692
+ var _records_find;
693
+ return viewerId ? (_records_find = records.find((record)=>record.$id === viewerId)) != null ? _records_find : null : null;
694
+ }, [
695
+ viewerId,
696
+ records
697
+ ]);
698
+ // --- Document editor (null id = new; AGL-179 typed inputs) --------------
699
+ const [editor, setEditor] = useState(null);
700
+ const handleOpenRecord = useCallback((record)=>()=>{
701
+ var _record_values, _ref;
702
+ if (!record) {
703
+ // The dataset's documents, not the loaded window (AGL-1716).
704
+ const quota = checkQuota(org, 'recordsPerDataset', recordCount);
705
+ if (!quota.allowed) {
706
+ return enqueueSnackbar(`Record limit reached (${quota.limit}) — see Billing to upgrade`, {
707
+ variant: 'warning',
708
+ persist: false
709
+ });
710
+ }
711
+ }
712
+ const values = {};
713
+ for (const fieldId of model.order){
714
+ const field = model.fields[fieldId];
715
+ if (!field) continue;
716
+ if (record) {
717
+ var _record_values1;
718
+ values[fieldId] = datasetValueToInput(field, (_record_values1 = record.values) == null ? void 0 : _record_values1[fieldId]);
719
+ } else if (field.default != null) {
720
+ values[fieldId] = String(field.default);
721
+ }
722
+ }
723
+ // Flag non-conforming stored values (AGL-178 policy: type changes
724
+ // never rewrite documents; the editor surfaces the mismatch).
725
+ const errors = record ? validateDocument(model, (_record_values = record.values) != null ? _record_values : {}) : {};
726
+ setEditor({
727
+ id: (_ref = record == null ? void 0 : record.$id) != null ? _ref : null,
728
+ values,
729
+ errors
730
+ });
731
+ }, [
732
+ org,
733
+ recordCount,
734
+ model,
735
+ enqueueSnackbar
736
+ ]);
737
+ const handleSaveRecord = useCallback(async ()=>{
738
+ if (!editor || !selected || !dataScope) return;
739
+ const coerced = coerceDocumentValues(model, editor.values);
740
+ const errors = validateDocument(model, coerced);
741
+ if (Object.keys(errors).length) {
742
+ return void setEditor((prev)=>prev ? _extends({}, prev, {
743
+ errors
744
+ }) : prev);
745
+ }
746
+ // `values` is replaced wholesale (not merged) so orphaned values from
747
+ // removed fields strip here, lazily, per the documented AGL-178 policy.
748
+ try {
749
+ if (editor.id) {
750
+ // Updates stay client-direct (no quota consumed).
751
+ const recordRef = doc(firestore, dataScope[0], dataScope[1], 'datasets', selected.$id, 'records', editor.id);
752
+ await setDoc(recordRef, _extends({
753
+ values: coerced
754
+ }, datasetIntegrityUpdate(model, coerced, deleteField()), {
755
+ updatedAt: Timestamp.now()
756
+ }), {
757
+ mergeFields: [
758
+ 'values',
759
+ 'referencedIds',
760
+ 'updatedAt'
761
+ ]
762
+ });
763
+ await announceRecords(selected.$id);
764
+ } else {
765
+ // Creates go through the quota-enforcing API (AGL-473). The legacy
766
+ // host-scope client write that used to follow this branch is gone
767
+ // (AGL-1050): a non-null `dataScope` is an org scope, so `orgId` is
768
+ // set and there is no third case to fall into.
769
+ await callDatasetApi({
770
+ action: 'create-record',
771
+ datasetId: selected.$id,
772
+ values: coerced
773
+ });
774
+ }
775
+ } catch (error) {
776
+ var _ref;
777
+ return void enqueueSnackbar((_ref = error == null ? void 0 : error.message) != null ? _ref : 'Record save failed', {
778
+ variant: 'warning',
779
+ persist: false
780
+ });
781
+ }
782
+ setEditor(null);
783
+ // A create moves the count; an update cannot. Same reason as the
784
+ // importer: the one-shot aggregate does not refresh itself.
785
+ if (!editor.id) setRecordCountEpoch((epoch)=>epoch + 1);
786
+ enqueueSnackbar(editor.id ? 'Record saved' : 'Record added', {
787
+ variant: 'success',
788
+ persist: false
789
+ });
790
+ logActivity(editor.id ? 'Updated record' : 'Added record', {
791
+ type: 'content',
792
+ id: selected.$id,
793
+ name: selected.displayName
794
+ });
795
+ }, [
796
+ editor,
797
+ selected,
798
+ firestore,
799
+ dataScope,
800
+ model,
801
+ callDatasetApi,
802
+ announceRecords,
803
+ enqueueSnackbar,
804
+ logActivity
805
+ ]);
806
+ const handleDeleteRecord = useCallback((record)=>async ()=>{
807
+ // Datasets whose records this delete rewrites through a reference
808
+ // fixup, beside the one the record belongs to. Each has pages of its
809
+ // own to refresh (AGL-3113).
810
+ const alsoChanged = new Set();
811
+ if (!selected || !dataScope) return;
812
+ /**
813
+ * Delete integrity (AGL-180): every collection whose model references
814
+ * this one is asked whether it still points at this record. `restrict`
815
+ * blocks the delete; `setNull` strips the FKey from the holders.
816
+ *
817
+ * The question is a QUERY over the denormalized `referencedIds` index,
818
+ * not a page of records filtered in the browser. A page answers for the
819
+ * rows it happened to fetch — an unordered `limit` is answered in
820
+ * document-id order over auto-ids, so it is an arbitrary sample — and a
821
+ * reference held by a row outside it reads as no reference at all: the
822
+ * delete goes through and `restrict` silently fails to restrict, leaving
823
+ * a document pointing at something that no longer exists. `values`
824
+ * itself cannot be queried (it carries a deliberate index exemption —
825
+ * see `datasetIntegrityFields`), which is why the index exists.
826
+ *
827
+ * The query narrows to the candidates; the per-field test below keeps it
828
+ * EXACT, since `referencedIds` is the union across every reference field
829
+ * and one of them may point at a different collection entirely.
830
+ *
831
+ * ⚠️ FAILS CLOSED. A query that rejects — a missing index, a rules
832
+ * refusal on a collection this member cannot read — refuses the delete
833
+ * rather than treating silence as "nothing references it". A check that
834
+ * fails open is worse than no check, because the UI then tells the user
835
+ * the record is safe to remove.
836
+ *
837
+ * ⚠️ Reaches the collections in `datasets`, which is this viewer's
838
+ * scoped, capped picker window. A referencing collection shared with no
839
+ * site this member can see is one the client may not read at all, so it
840
+ * is not consulted; only a server route could close that.
841
+ */ for (const other of datasets){
842
+ const otherModel = effectiveDatasetModel(other);
843
+ const referencing = otherModel.order.filter((fieldId)=>{
844
+ var _otherModel_fields_fieldId, _otherModel_fields_fieldId_reference, _otherModel_fields_fieldId1;
845
+ return ((_otherModel_fields_fieldId = otherModel.fields[fieldId]) == null ? void 0 : _otherModel_fields_fieldId.type) === 'reference' && ((_otherModel_fields_fieldId1 = otherModel.fields[fieldId]) == null ? void 0 : (_otherModel_fields_fieldId_reference = _otherModel_fields_fieldId1.reference) == null ? void 0 : _otherModel_fields_fieldId_reference.datasetId) === selected.$id;
846
+ });
847
+ if (!referencing.length) continue;
848
+ let snapshot;
849
+ try {
850
+ snapshot = await getDocs(query(collection(firestore, dataScope[0], dataScope[1], 'datasets', other.$id, 'records'), where('referencedIds', 'array-contains', record.$id)));
851
+ } catch (unused) {
852
+ return void enqueueSnackbar(`Cannot delete: "${other.displayName}" could not be checked for ` + 'references, so removing this record could break it. Nothing ' + 'was deleted.', {
853
+ variant: 'error'
854
+ });
855
+ }
856
+ const hits = snapshot.docs.filter((docSnapshot)=>referencing.some((fieldId)=>{
857
+ var _docSnapshot_get;
858
+ const stored = (_docSnapshot_get = docSnapshot.get('values')) == null ? void 0 : _docSnapshot_get[fieldId];
859
+ return Array.isArray(stored) ? stored.includes(record.$id) : stored === record.$id;
860
+ }));
861
+ if (!hits.length) continue;
862
+ const restricted = referencing.some((fieldId)=>{
863
+ var _otherModel_fields_fieldId_reference, _otherModel_fields_fieldId;
864
+ 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';
865
+ });
866
+ if (restricted) {
867
+ return void enqueueSnackbar(`Cannot delete: referenced by ${hits.length} document` + `${hits.length === 1 ? '' : 's'} in "${other.displayName}"`, {
868
+ variant: 'warning',
869
+ persist: false
870
+ });
871
+ }
872
+ // Chunked under Firestore's 500-writes-per-batch cap. The query is no
873
+ // longer bounded by a page size, so the number of holders is whatever
874
+ // the collection really holds.
875
+ for(let start = 0; start < hits.length; start += 400){
876
+ const batch = writeBatch(firestore);
877
+ for (const hit of hits.slice(start, start + 400)){
878
+ var _hit_get;
879
+ const values = _extends({}, (_hit_get = hit.get('values')) != null ? _hit_get : {});
880
+ for (const fieldId of referencing){
881
+ const stored = values[fieldId];
882
+ if (Array.isArray(stored)) {
883
+ values[fieldId] = stored.filter((id)=>id !== record.$id);
884
+ } else if (stored === record.$id) {
885
+ delete values[fieldId];
886
+ }
887
+ }
888
+ batch.update(hit.ref, _extends({
889
+ values
890
+ }, datasetIntegrityUpdate(otherModel, values, deleteField())));
891
+ }
892
+ await batch.commit();
893
+ }
894
+ // The fixups rewrote rows in ANOTHER dataset, whose own pages are now
895
+ // showing a reference that no longer resolves.
896
+ alsoChanged.add(other.$id);
897
+ }
898
+ await deleteDoc(doc(firestore, dataScope[0], dataScope[1], 'datasets', selected.$id, 'records', record.$id));
899
+ setRecordCountEpoch((epoch)=>epoch + 1);
900
+ for (const datasetId of [
901
+ selected.$id,
902
+ ...alsoChanged
903
+ ]){
904
+ await announceRecords(datasetId);
905
+ }
906
+ enqueueSnackbar('Record deleted', {
907
+ variant: 'success',
908
+ persist: false
909
+ });
910
+ }, [
911
+ selected,
912
+ datasets,
913
+ firestore,
914
+ dataScope,
915
+ announceRecords,
916
+ enqueueSnackbar
917
+ ]);
918
+ // CSV/JSON round-tripping (AGL-182).
919
+ const download = useCallback((name, mime, text)=>{
920
+ const url = URL.createObjectURL(new Blob([
921
+ text
922
+ ], {
923
+ type: mime
924
+ }));
925
+ const anchor = document.createElement('a');
926
+ anchor.href = url;
927
+ anchor.download = name;
928
+ anchor.click();
929
+ URL.revokeObjectURL(url);
930
+ }, []);
931
+ const [exporting, setExporting] = useState(null);
932
+ /**
933
+ * The export is a SERVER stream now (AGL-2335).
934
+ *
935
+ * It used to serialize `records` — the card's live listener window, which
936
+ * is `limit(500)` with no `orderBy`. Firestore answers an unordered limit
937
+ * in document-id order over auto-ids, so a 2,000-row dataset did not
938
+ * export "the first 500": it exported 500 unpredictable rows, and running
939
+ * it twice could produce different ones. `sortDatasetRecords` then sorted
940
+ * that sample, so the file looked ordered and was arbitrary. The button
941
+ * said `CSV`, and the agency guide calls the result a *full handover*.
942
+ *
943
+ * Fetching the whole collection here instead was not an option — the
944
+ * agency plan's `recordsPerDataset` is UNLIMITED, so an unbounded
945
+ * client-side read is a browser hang and a large read bill behind a button
946
+ * labelled `CSV`. `/api/orgs/datasets/export` pages and streams it.
947
+ *
948
+ * The response is CHECKED, not trusted: the route reports a `count()`
949
+ * aggregate in `X-Aglyn-Export-Rows`, and a body with fewer rows than that
950
+ * is refused rather than saved. A stream that dies halfway produces a
951
+ * perfectly well-formed shorter file — nothing about the bytes says they
952
+ * are short, which is precisely how the old truncation stayed invisible.
953
+ */ const handleExport = useCallback((format)=>async ()=>{
954
+ if (!(selected == null ? void 0 : selected.$id) || !orgId || exporting) return;
955
+ setExporting(format);
956
+ try {
957
+ var _selected_displayName;
958
+ const response = await authorizedFetch(user, `/api/orgs/datasets/export?orgId=${encodeURIComponent(orgId)}` + `&datasetId=${encodeURIComponent(selected.$id)}` + `&format=${format}`);
959
+ if (!response.ok) {
960
+ var _ref;
961
+ const failure = await response.json().catch(()=>({}));
962
+ throw new Error((_ref = failure == null ? void 0 : failure.error) != null ? _ref : 'Export failed');
963
+ }
964
+ const text = await response.text();
965
+ const { promised, received, short } = exportShortfall(response.headers.get('X-Aglyn-Export-Rows'), text, format);
966
+ if (short) {
967
+ enqueueSnackbar(`Export incomplete — ${received} of ${promised} documents ` + 'arrived. Nothing was saved; try again.', {
968
+ variant: 'error'
969
+ });
970
+ return;
971
+ }
972
+ const base = String((_selected_displayName = selected.displayName) != null ? _selected_displayName : 'collection').replace(/[^A-Za-z0-9_-]+/g, '-').toLowerCase() || 'collection';
973
+ download(`${base}.${format}`, format === 'csv' ? 'text/csv' : 'application/json', text);
974
+ } catch (error) {
975
+ enqueueSnackbar(error instanceof Error ? error.message : 'Export failed', {
976
+ variant: 'error'
977
+ });
978
+ } finally{
979
+ setExporting(null);
980
+ }
981
+ }, [
982
+ selected,
983
+ orgId,
984
+ user,
985
+ exporting,
986
+ download,
987
+ enqueueSnackbar
988
+ ]);
989
+ // keyField (wave v6): optional unique key — matching rows update the
990
+ // existing record instead of appending a duplicate.
991
+ const [importer, setImporter] = useState(null);
992
+ const importPreview = useMemo(()=>{
993
+ if (!importer) return null;
994
+ const rows = parseImportRows(importer.text);
995
+ if (!rows || !rows.length) return null;
996
+ const { mapping, unmatched } = mapImportColumns(model, Object.keys(rows[0]));
997
+ const prepared = rows.map((raw)=>{
998
+ const input = {};
999
+ for (const [column, fieldId] of Object.entries(mapping)){
1000
+ var _raw_column;
1001
+ input[fieldId] = (_raw_column = raw[column]) != null ? _raw_column : '';
1002
+ }
1003
+ const values = coerceDocumentValues(model, input);
1004
+ const errors = validateDocument(model, values);
1005
+ return {
1006
+ values,
1007
+ errors,
1008
+ valid: !Object.keys(errors).length
1009
+ };
1010
+ });
1011
+ return {
1012
+ prepared,
1013
+ unmatched,
1014
+ valid: prepared.filter((row)=>row.valid).length
1015
+ };
1016
+ }, [
1017
+ importer,
1018
+ model
1019
+ ]);
1020
+ const handleImport = useCallback(async ()=>{
1021
+ var _ref, _quota_limit;
1022
+ if (!selected || !importPreview || !dataScope) return;
1023
+ const validRows = importPreview.prepared.filter((row)=>row.valid);
1024
+ // Unique-key upsert (wave v6): with a key field picked, incoming rows
1025
+ // that match an existing record's key update it in place, and
1026
+ // duplicate keys within the import collapse to the last row.
1027
+ const keyField = (_ref = importer == null ? void 0 : importer.keyField) != null ? _ref : '';
1028
+ const keyOf = (values)=>{
1029
+ var _ref;
1030
+ return String((_ref = values == null ? void 0 : values[keyField]) != null ? _ref : '').trim().toLowerCase();
1031
+ };
1032
+ let updates = [];
1033
+ let creates = validRows;
1034
+ if (keyField) {
1035
+ var _ref1;
1036
+ /*
1037
+ * The key index is its OWN read, not the table's window.
1038
+ *
1039
+ * It used to be built from the card's live listener, which is now a
1040
+ * PAGE — and a page cannot answer "does this key already exist". An
1041
+ * upsert that consults ten rows would turn nearly every update into a
1042
+ * create: duplicate rows, and quota spent on them.
1043
+ *
1044
+ * So it is read here, once, at the moment a key field is actually
1045
+ * chosen — which is also strictly cheaper than the listener it replaces,
1046
+ * since that one paid for five hundred documents on every mount of a
1047
+ * card nobody had scrolled.
1048
+ *
1049
+ * ⚠️ STILL BOUNDED, and the bound is honest rather than hidden. An
1050
+ * unbounded client read is what AGL-2335 refused for the export, for the
1051
+ * same reason: `recordsPerDataset` is unlimited on the agency plan, so
1052
+ * "read them all" is a browser hang behind a dialog button. Past the
1053
+ * ceiling the reader is TOLD which keys could be consulted instead of
1054
+ * being handed silent duplicates — `recordCount` is the server
1055
+ * aggregate, so that comparison is exact.
1056
+ */ const existingByKey = new Map();
1057
+ const keyWindow = await getDocs(query(collection(firestore, dataScope[0], dataScope[1], 'datasets', selected.$id, 'records'), orderBy(documentId()), limit(IMPORT_KEY_WINDOW))).catch(()=>null);
1058
+ for (const snapshot of (_ref1 = keyWindow == null ? void 0 : keyWindow.docs) != null ? _ref1 : []){
1059
+ var _snapshot_get;
1060
+ const key = keyOf((_snapshot_get = snapshot.get('values')) != null ? _snapshot_get : {});
1061
+ if (key) existingByKey.set(key, snapshot.id);
1062
+ }
1063
+ if (recordCount > IMPORT_KEY_WINDOW) {
1064
+ 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.', {
1065
+ variant: 'warning',
1066
+ persist: true
1067
+ });
1068
+ }
1069
+ const deduped = new Map();
1070
+ const keyless = [];
1071
+ for (const row of validRows){
1072
+ const key = keyOf(row.values);
1073
+ if (key) deduped.set(key, row);
1074
+ else keyless.push(row);
1075
+ }
1076
+ updates = [];
1077
+ creates = [
1078
+ ...keyless
1079
+ ];
1080
+ for (const [key, row] of deduped){
1081
+ const existingId = existingByKey.get(key);
1082
+ if (existingId) updates.push({
1083
+ id: existingId,
1084
+ values: row.values
1085
+ });
1086
+ else creates.push(row);
1087
+ }
1088
+ }
1089
+ // Against the dataset's real size, not the loaded window (AGL-1716) —
1090
+ // `room` subtracts this number from the limit, so an understated count
1091
+ // did not merely fail to refuse, it INFLATED the slots offered.
1092
+ const quota = checkQuota(org, 'recordsPerDataset', recordCount + creates.length - 1);
1093
+ const room = quota.allowed ? creates.length : Math.max(0, Number((_quota_limit = quota.limit) != null ? _quota_limit : 0) - recordCount);
1094
+ const toWrite = creates.slice(0, room);
1095
+ if (!toWrite.length && !updates.length) {
1096
+ return void enqueueSnackbar(`Record limit reached (${quota.limit}) — see Billing to upgrade`, {
1097
+ variant: 'warning',
1098
+ persist: false
1099
+ });
1100
+ }
1101
+ // Updates stay client-direct (no quota consumed), chunked under
1102
+ // Firestore's 500-writes/batch limit.
1103
+ for(let start = 0; start < updates.length; start += 400){
1104
+ const batch = writeBatch(firestore);
1105
+ updates.slice(start, start + 400).forEach((update)=>{
1106
+ const ref = doc(firestore, dataScope[0], dataScope[1], 'datasets', selected.$id, 'records', update.id);
1107
+ batch.set(ref, _extends({
1108
+ values: update.values
1109
+ }, datasetIntegrityUpdate(model, update.values, deleteField()), {
1110
+ updatedAt: Timestamp.now()
1111
+ }), {
1112
+ merge: true
1113
+ });
1114
+ });
1115
+ await batch.commit();
1116
+ }
1117
+ // Creates go through the quota-enforcing API (AGL-473). The legacy
1118
+ // host-scope client batch that used to be the `else` here is gone
1119
+ // (AGL-1050) — it was the largest of the quota bypasses, since a CSV
1120
+ // import could create records by the hundred without ever asking.
1121
+ try {
1122
+ if (toWrite.length) {
1123
+ await callDatasetApi({
1124
+ action: 'import-records',
1125
+ datasetId: selected.$id,
1126
+ records: toWrite.map((row)=>({
1127
+ values: row.values
1128
+ }))
1129
+ });
1130
+ } else if (updates.length) {
1131
+ // An import that only UPDATED existing rows never reaches the server
1132
+ // leg, so it has to announce for itself — otherwise the one import
1133
+ // shape that changes every row of a dataset is the one that refreshes
1134
+ // nothing (AGL-3113).
1135
+ await announceRecords(selected.$id);
1136
+ }
1137
+ } catch (error) {
1138
+ var _ref2;
1139
+ return void enqueueSnackbar((_ref2 = error == null ? void 0 : error.message) != null ? _ref2 : 'Import failed', {
1140
+ variant: 'warning',
1141
+ persist: false
1142
+ });
1143
+ }
1144
+ setImporter(null);
1145
+ // An import moves the record count and the aggregate is a one-shot —
1146
+ // the listener refreshes the ROWS for free, the count has to be asked
1147
+ // again or the next import prices its room off a pre-import number.
1148
+ setRecordCountEpoch((epoch)=>epoch + 1);
1149
+ const skippedInvalid = importPreview.prepared.length - validRows.length;
1150
+ const skippedQuota = creates.length - toWrite.length;
1151
+ enqueueSnackbar(`Imported ${toWrite.length} new` + (updates.length ? `, updated ${updates.length}` : '') + (skippedInvalid ? `, ${skippedInvalid} invalid skipped` : '') + (skippedQuota ? `, ${skippedQuota} over the record limit` : ''), {
1152
+ variant: skippedInvalid || skippedQuota ? 'warning' : 'success'
1153
+ });
1154
+ logActivity('Imported records', {
1155
+ type: 'content',
1156
+ id: selected.$id,
1157
+ name: selected.displayName
1158
+ });
1159
+ }, [
1160
+ selected,
1161
+ importPreview,
1162
+ importer == null ? void 0 : importer.keyField,
1163
+ org,
1164
+ model,
1165
+ recordCount,
1166
+ firestore,
1167
+ dataScope,
1168
+ callDatasetApi,
1169
+ announceRecords,
1170
+ enqueueSnackbar,
1171
+ logActivity
1172
+ ]);
1173
+ return /*#__PURE__*/ _jsxs(CardDisplay, {
1174
+ header: 'Data',
1175
+ help: pluginDocsHelp('datasets', {
1176
+ anchor: '#model-builder'
1177
+ }),
1178
+ contentGutterX: true,
1179
+ contentGutterY: true,
1180
+ contentBordered: "all",
1181
+ children: [
1182
+ /*#__PURE__*/ _jsxs(Stack, {
1183
+ spacing: 1.5,
1184
+ children: [
1185
+ orgId ? /*#__PURE__*/ _jsx(Typography, {
1186
+ variant: "caption",
1187
+ color: "text.secondary",
1188
+ children: newDatasetSharingNote(createsSiteOnly)
1189
+ }) : null,
1190
+ datasets.length === 0 ? /*#__PURE__*/ _jsx(Typography, {
1191
+ variant: "body2",
1192
+ color: "text.secondary",
1193
+ children: 'Create a dataset (e.g. Products) and repeat a component over ' + 'its records with {{item.field}} bindings.'
1194
+ }) : /*#__PURE__*/ _jsxs(Stack, {
1195
+ direction: "row",
1196
+ spacing: 1,
1197
+ sx: {
1198
+ alignItems: 'center'
1199
+ },
1200
+ children: [
1201
+ /*#__PURE__*/ _jsx(TextField, {
1202
+ select: true,
1203
+ size: "small",
1204
+ label: "Dataset",
1205
+ value: (_ref1 = selected == null ? void 0 : selected.$id) != null ? _ref1 : '',
1206
+ onChange: (event)=>setSelectedId(event.target.value),
1207
+ sx: {
1208
+ minWidth: 180
1209
+ },
1210
+ children: datasets.map((item)=>/*#__PURE__*/ _jsx(MenuItem, {
1211
+ value: item.$id,
1212
+ children: item.displayName
1213
+ }, item.$id))
1214
+ }),
1215
+ /*#__PURE__*/ _jsx(Button, {
1216
+ size: "small",
1217
+ color: "primary",
1218
+ onClick: handleOpenRecord(),
1219
+ children: 'Add record'
1220
+ }),
1221
+ /*#__PURE__*/ _jsx(Button, {
1222
+ size: "small",
1223
+ onClick: ()=>setSchemaOpen(true),
1224
+ children: 'Schema'
1225
+ }),
1226
+ /*#__PURE__*/ _jsx(Button, {
1227
+ size: "small",
1228
+ onClick: ()=>setImporter({
1229
+ text: '',
1230
+ keyField: ''
1231
+ }),
1232
+ children: 'Import'
1233
+ }),
1234
+ recordCount || records.length ? /*#__PURE__*/ _jsxs(_Fragment, {
1235
+ children: [
1236
+ /*#__PURE__*/ _jsx(Button, {
1237
+ size: "small",
1238
+ disabled: Boolean(exporting),
1239
+ onClick: handleExport('csv'),
1240
+ children: exporting === 'csv' ? 'Exporting…' : 'CSV'
1241
+ }),
1242
+ /*#__PURE__*/ _jsx(Button, {
1243
+ size: "small",
1244
+ disabled: Boolean(exporting),
1245
+ onClick: handleExport('json'),
1246
+ children: exporting === 'json' ? 'Exporting…' : 'JSON'
1247
+ })
1248
+ ]
1249
+ }) : null,
1250
+ /*#__PURE__*/ _jsx(Button, {
1251
+ size: "small",
1252
+ color: "error",
1253
+ onClick: handleDeleteDataset,
1254
+ children: 'Delete'
1255
+ })
1256
+ ]
1257
+ }),
1258
+ selected ? /*#__PURE__*/ _jsx(QuotaReadoutComponent, {
1259
+ ready: org != null,
1260
+ used: recordCount,
1261
+ limit: checkQuota(org, 'recordsPerDataset', recordCount).limit,
1262
+ noun: "record"
1263
+ }) : null,
1264
+ selected && records.length > 0 ? /*#__PURE__*/ _jsxs(Stack, {
1265
+ direction: "row",
1266
+ spacing: 1,
1267
+ children: [
1268
+ /*#__PURE__*/ _jsx(TextField, {
1269
+ size: "small",
1270
+ label: "Filter",
1271
+ value: filterText,
1272
+ onChange: (event)=>setFilterText(event.target.value),
1273
+ helperText: 'e.g. price <= 20 · applies to this page',
1274
+ sx: {
1275
+ minWidth: 220
1276
+ }
1277
+ }),
1278
+ /*#__PURE__*/ _jsx(TextField, {
1279
+ size: "small",
1280
+ label: "Sort",
1281
+ value: sortText,
1282
+ onChange: (event)=>setSortText(event.target.value),
1283
+ helperText: 'e.g. price desc',
1284
+ sx: {
1285
+ minWidth: 140
1286
+ }
1287
+ })
1288
+ ]
1289
+ }) : null,
1290
+ selected && (records.length > 0 || recordPage > 0) ? /*#__PURE__*/ _jsxs(_Fragment, {
1291
+ children: [
1292
+ /*#__PURE__*/ _jsxs(ScrollTable, {
1293
+ size: "small",
1294
+ children: [
1295
+ /*#__PURE__*/ _jsx(TableHead, {
1296
+ children: /*#__PURE__*/ _jsxs(TableRow, {
1297
+ children: [
1298
+ fields.map((fieldId)=>{
1299
+ var _field_name, _ref;
1300
+ const field = model.fields[fieldId];
1301
+ // Field descriptions (AGL-560) surface as header hints.
1302
+ return /*#__PURE__*/ _jsx(TableCell, {
1303
+ children: (field == null ? void 0 : field.description) ? /*#__PURE__*/ _jsx(Tooltip, {
1304
+ title: field.description,
1305
+ children: /*#__PURE__*/ _jsx("span", {
1306
+ style: {
1307
+ cursor: 'help'
1308
+ },
1309
+ children: (_field_name = field.name) != null ? _field_name : fieldId
1310
+ })
1311
+ }) : (_ref = field == null ? void 0 : field.name) != null ? _ref : fieldId
1312
+ }, fieldId);
1313
+ }),
1314
+ /*#__PURE__*/ _jsx(TableCell, {
1315
+ align: "right",
1316
+ children: 'Actions'
1317
+ })
1318
+ ]
1319
+ })
1320
+ }),
1321
+ /*#__PURE__*/ _jsx(TableBody, {
1322
+ children: visibleRecords.map((record)=>/*
1323
+ The row OPENS the record, read-only. Reading a record and
1324
+ changing one used to be the same gesture: `Edit` was the only
1325
+ way to see a document's full contents, so every look opened a
1326
+ form whose primary button writes.
1327
+
1328
+ The click is the mouse affordance and nothing else — a `<tr>`
1329
+ cannot be made into a real activatable control without
1330
+ overriding the `row` role a table depends on. The keyboard's
1331
+ affordance is the `View` button in the actions cluster: a
1332
+ genuine `<button>`, in the tab order, announced as one, with
1333
+ the theme's focus ring. Both reach the same handler.
1334
+ */ /*#__PURE__*/ _jsxs(TableRow, {
1335
+ hover: true,
1336
+ onClick: ()=>setViewerId(record.$id),
1337
+ sx: {
1338
+ cursor: 'pointer'
1339
+ },
1340
+ children: [
1341
+ fields.map((fieldId)=>{
1342
+ var _model_fields_fieldId, _record_values, _record_values1;
1343
+ return /*#__PURE__*/ _jsx(TableCell, {
1344
+ children: ((_model_fields_fieldId = model.fields[fieldId]) == null ? void 0 : _model_fields_fieldId.type) === 'reference' ? referenceLabel(fieldId, (_record_values = record.values) == null ? void 0 : _record_values[fieldId]) || '--' : model.fields[fieldId] ? formatDatasetValue(model.fields[fieldId], (_record_values1 = record.values) == null ? void 0 : _record_values1[fieldId]) || '--' : '--'
1345
+ }, fieldId);
1346
+ }),
1347
+ /*#__PURE__*/ _jsxs(TableCell, {
1348
+ align: "right",
1349
+ sx: {
1350
+ whiteSpace: 'nowrap'
1351
+ },
1352
+ /*
1353
+ Every control in this cell is its OWN action. Without
1354
+ this the row's handler fires too, so `Delete` would open
1355
+ the viewer behind its own destructive write and `Edit`
1356
+ would leave a read-only dialog stacked under the form —
1357
+ the kind of defect nobody reports and everybody works
1358
+ around.
1359
+ */ onClick: (event)=>event.stopPropagation(),
1360
+ children: [
1361
+ /*#__PURE__*/ _jsx(Button, {
1362
+ size: "small",
1363
+ onClick: ()=>setViewerId(record.$id),
1364
+ children: 'View'
1365
+ }),
1366
+ /*#__PURE__*/ _jsx(Button, {
1367
+ size: "small",
1368
+ onClick: handleOpenRecord(record),
1369
+ children: 'Edit'
1370
+ }),
1371
+ /*#__PURE__*/ _jsx(Button, {
1372
+ size: "small",
1373
+ color: "error",
1374
+ onClick: handleDeleteRecord(record),
1375
+ children: 'Delete'
1376
+ })
1377
+ ]
1378
+ })
1379
+ ]
1380
+ }, record.$id))
1381
+ })
1382
+ ]
1383
+ }),
1384
+ /*#__PURE__*/ _jsx(ListPagination, {
1385
+ page: recordPage,
1386
+ pageSize: recordPageSize,
1387
+ rowCount: records.length,
1388
+ hasMore: hasMoreRecords,
1389
+ onPageChange: setRecordPage,
1390
+ onPageSizeChange: setRecordPageSize
1391
+ })
1392
+ ]
1393
+ }) : selected ? /*#__PURE__*/ _jsx(Typography, {
1394
+ variant: "body2",
1395
+ color: "text.secondary",
1396
+ children: 'No records yet.'
1397
+ }) : null,
1398
+ /*#__PURE__*/ _jsxs(Stack, {
1399
+ direction: "row",
1400
+ spacing: 1,
1401
+ children: [
1402
+ /*#__PURE__*/ _jsx(Button, {
1403
+ size: "small",
1404
+ variant: "outlined",
1405
+ color: "primary",
1406
+ onClick: handleOpenCreator,
1407
+ children: 'Add dataset'
1408
+ }),
1409
+ datasets.length >= 2 ? /*#__PURE__*/ _jsx(Button, {
1410
+ size: "small",
1411
+ onClick: ()=>setJoiner({
1412
+ a: '',
1413
+ b: ''
1414
+ }),
1415
+ children: 'Add join collection'
1416
+ }) : null
1417
+ ]
1418
+ })
1419
+ ]
1420
+ }),
1421
+ /*#__PURE__*/ _jsxs(Dialog, {
1422
+ open: Boolean(creator),
1423
+ onClose: ()=>setCreator(null),
1424
+ maxWidth: "xs",
1425
+ fullWidth: true,
1426
+ children: [
1427
+ /*#__PURE__*/ _jsx(DialogTitle, {
1428
+ children: 'New dataset'
1429
+ }),
1430
+ /*#__PURE__*/ _jsxs(DialogContent, {
1431
+ sx: {
1432
+ display: 'flex',
1433
+ flexDirection: 'column',
1434
+ gap: 2
1435
+ },
1436
+ children: [
1437
+ /*#__PURE__*/ _jsx(TextField, {
1438
+ label: "Name",
1439
+ value: (_ref2 = creator == null ? void 0 : creator.name) != null ? _ref2 : '',
1440
+ onChange: (event)=>setCreator((prev)=>prev ? _extends({}, prev, {
1441
+ name: event.target.value
1442
+ }) : prev),
1443
+ size: "small",
1444
+ autoFocus: true,
1445
+ sx: {
1446
+ mt: 1
1447
+ },
1448
+ helperText: "e.g. Products, Team, FAQ"
1449
+ }),
1450
+ /*#__PURE__*/ _jsx(TextField, {
1451
+ label: "Fields",
1452
+ value: (_ref3 = creator == null ? void 0 : creator.fields) != null ? _ref3 : '',
1453
+ onChange: (event)=>setCreator((prev)=>prev ? _extends({}, prev, {
1454
+ fields: event.target.value
1455
+ }) : prev),
1456
+ size: "small",
1457
+ helperText: creatorEntries.length ? `Columns: ${creatorEntries.map((entry)=>entry.name).join(', ')}` : 'Comma-separated column names, e.g. Title, Unit price'
1458
+ })
1459
+ ]
1460
+ }),
1461
+ /*#__PURE__*/ _jsxs(DialogActions, {
1462
+ children: [
1463
+ /*#__PURE__*/ _jsx(Button, {
1464
+ onClick: ()=>setCreator(null),
1465
+ children: 'Cancel'
1466
+ }),
1467
+ /*#__PURE__*/ _jsx(Button, {
1468
+ variant: "contained",
1469
+ color: "primary",
1470
+ disabled: !scopeReady || !(creator == null ? void 0 : creator.name.trim()) || creatorFields.length === 0,
1471
+ onClick: handleCreate,
1472
+ children: 'Create'
1473
+ })
1474
+ ]
1475
+ })
1476
+ ]
1477
+ }),
1478
+ /*#__PURE__*/ _jsx(DatasetRecordDialog, {
1479
+ record: viewerRecord,
1480
+ model: model,
1481
+ resolveReference: resolveReferenceLabel,
1482
+ onClose: ()=>setViewerId(null),
1483
+ // Explicitly separate, and a hand-off rather than a second editor:
1484
+ // the viewer closes and the record opens in the dialog that has
1485
+ // always owned writing.
1486
+ onEdit: ()=>{
1487
+ const record = viewerRecord;
1488
+ setViewerId(null);
1489
+ if (record) handleOpenRecord(record)();
1490
+ }
1491
+ }),
1492
+ /*#__PURE__*/ _jsxs(Dialog, {
1493
+ open: Boolean(editor),
1494
+ onClose: ()=>setEditor(null),
1495
+ maxWidth: "xs",
1496
+ fullWidth: true,
1497
+ children: [
1498
+ /*#__PURE__*/ _jsx(DialogTitle, {
1499
+ children: (editor == null ? void 0 : editor.id) ? 'Edit record' : 'New record'
1500
+ }),
1501
+ /*#__PURE__*/ _jsx(DialogContent, {
1502
+ sx: {
1503
+ display: 'flex',
1504
+ flexDirection: 'column',
1505
+ gap: 2
1506
+ },
1507
+ children: fields.map((fieldId, index)=>{
1508
+ var _ref;
1509
+ var _field_validation_options, _field_validation;
1510
+ const field = model.fields[fieldId];
1511
+ if (!field) return null;
1512
+ const value = (_ref = editor == null ? void 0 : editor.values[fieldId]) != null ? _ref : '';
1513
+ const error = editor == null ? void 0 : editor.errors[fieldId];
1514
+ const label = field.required ? `${field.name} *` : field.name;
1515
+ const setValue = (nextValue)=>setEditor((prev)=>prev ? _extends({}, prev, {
1516
+ values: _extends({}, prev.values, {
1517
+ [fieldId]: nextValue
1518
+ }),
1519
+ errors: _extends({}, prev.errors, {
1520
+ [fieldId]: ''
1521
+ })
1522
+ }) : prev);
1523
+ const common = {
1524
+ label,
1525
+ value,
1526
+ size: 'small',
1527
+ autoFocus: index === 0,
1528
+ error: Boolean(error),
1529
+ helperText: error || field.description,
1530
+ sx: index === 0 ? {
1531
+ mt: 1
1532
+ } : undefined,
1533
+ onChange: (event)=>setValue(event.target.value)
1534
+ };
1535
+ // Custom field types (AGL-434): the declaring plugin's Input
1536
+ // takes over; values stay in input form (strings) so the
1537
+ // existing coerce/validate pipeline is unchanged.
1538
+ const customFieldType = getCustomFieldType(field.customType);
1539
+ if (customFieldType == null ? void 0 : customFieldType.Input) {
1540
+ const CustomInput = customFieldType.Input;
1541
+ return /*#__PURE__*/ _jsxs(Stack, {
1542
+ sx: index === 0 ? {
1543
+ mt: 1
1544
+ } : undefined,
1545
+ children: [
1546
+ /*#__PURE__*/ _jsx(CustomInput, {
1547
+ value: value,
1548
+ label: common.label,
1549
+ onChange: (next)=>setValue(String(next != null ? next : ''))
1550
+ }),
1551
+ error || field.description ? /*#__PURE__*/ _jsx(Typography, {
1552
+ variant: "caption",
1553
+ color: error ? 'error' : 'text.secondary',
1554
+ children: error || field.description
1555
+ }) : null
1556
+ ]
1557
+ }, fieldId);
1558
+ }
1559
+ // Typed inputs per FT.Tag (AGL-179).
1560
+ if (field.type === 'bool') {
1561
+ return /*#__PURE__*/ _jsxs(TextField, _extends({}, common, {
1562
+ select: true,
1563
+ children: [
1564
+ /*#__PURE__*/ _jsx(MenuItem, {
1565
+ value: "",
1566
+ children: '—'
1567
+ }),
1568
+ /*#__PURE__*/ _jsx(MenuItem, {
1569
+ value: "true",
1570
+ children: 'Yes'
1571
+ }),
1572
+ /*#__PURE__*/ _jsx(MenuItem, {
1573
+ value: "false",
1574
+ children: 'No'
1575
+ })
1576
+ ]
1577
+ }), fieldId);
1578
+ }
1579
+ if (field.type === 'text' && ((_field_validation = field.validation) == null ? void 0 : (_field_validation_options = _field_validation.options) == null ? void 0 : _field_validation_options.length)) {
1580
+ return /*#__PURE__*/ _jsxs(TextField, _extends({}, common, {
1581
+ select: true,
1582
+ children: [
1583
+ /*#__PURE__*/ _jsx(MenuItem, {
1584
+ value: "",
1585
+ children: '—'
1586
+ }),
1587
+ field.validation.options.map((option)=>/*#__PURE__*/ _jsx(MenuItem, {
1588
+ value: option,
1589
+ children: option
1590
+ }, option))
1591
+ ]
1592
+ }), fieldId);
1593
+ }
1594
+ if (field.type === 'reference') {
1595
+ var _refOptions_fieldId;
1596
+ var _field_reference;
1597
+ const options = (_refOptions_fieldId = refOptions[fieldId]) != null ? _refOptions_fieldId : [];
1598
+ const multiple = Boolean((_field_reference = field.reference) == null ? void 0 : _field_reference.multiple);
1599
+ const selectedIds = value.split(',').map((part)=>part.trim()).filter(Boolean);
1600
+ return /*#__PURE__*/ _jsxs(TextField, _extends({}, common, {
1601
+ select: true,
1602
+ value: multiple ? selectedIds : value,
1603
+ slotProps: {
1604
+ select: multiple ? {
1605
+ multiple: true,
1606
+ onChange: (event)=>setValue(event.target.value.join(', '))
1607
+ } : undefined
1608
+ },
1609
+ children: [
1610
+ multiple ? null : /*#__PURE__*/ _jsx(MenuItem, {
1611
+ value: "",
1612
+ children: '—'
1613
+ }),
1614
+ options.map((option)=>/*#__PURE__*/ _jsx(MenuItem, {
1615
+ value: option.id,
1616
+ children: option.label
1617
+ }, option.id))
1618
+ ]
1619
+ }), fieldId);
1620
+ }
1621
+ if (field.type === 'int32' || field.type === 'int64' || field.type === 'float') {
1622
+ return /*#__PURE__*/ _jsx(TextField, _extends({}, common, {
1623
+ type: "number"
1624
+ }), fieldId);
1625
+ }
1626
+ if (field.type === 'timestamp') {
1627
+ return /*#__PURE__*/ _jsx(TextField, _extends({}, common, {
1628
+ type: "datetime-local",
1629
+ slotProps: {
1630
+ inputLabel: {
1631
+ shrink: true
1632
+ }
1633
+ }
1634
+ }), fieldId);
1635
+ }
1636
+ if (field.type === 'coordinates') {
1637
+ return /*#__PURE__*/ _jsx(TextField, _extends({}, common, {
1638
+ helperText: error || field.description || 'lat, lon'
1639
+ }), fieldId);
1640
+ }
1641
+ if (field.type === 'sorted') {
1642
+ return /*#__PURE__*/ _jsx(TextField, _extends({}, common, {
1643
+ helperText: error || field.description || 'Comma-separated list'
1644
+ }), fieldId);
1645
+ }
1646
+ return /*#__PURE__*/ _jsx(TextField, _extends({}, common), fieldId);
1647
+ })
1648
+ }),
1649
+ /*#__PURE__*/ _jsxs(DialogActions, {
1650
+ children: [
1651
+ /*#__PURE__*/ _jsx(Button, {
1652
+ onClick: ()=>setEditor(null),
1653
+ children: 'Cancel'
1654
+ }),
1655
+ /*#__PURE__*/ _jsx(Button, {
1656
+ variant: "contained",
1657
+ color: "primary",
1658
+ onClick: handleSaveRecord,
1659
+ children: (editor == null ? void 0 : editor.id) ? 'Save' : 'Add'
1660
+ })
1661
+ ]
1662
+ })
1663
+ ]
1664
+ }),
1665
+ /*#__PURE__*/ _jsxs(Dialog, {
1666
+ open: Boolean(importer),
1667
+ onClose: ()=>setImporter(null),
1668
+ maxWidth: "sm",
1669
+ fullWidth: true,
1670
+ children: [
1671
+ /*#__PURE__*/ _jsx(DialogTitle, {
1672
+ children: 'Import records'
1673
+ }),
1674
+ /*#__PURE__*/ _jsxs(DialogContent, {
1675
+ sx: {
1676
+ display: 'flex',
1677
+ flexDirection: 'column',
1678
+ gap: 2
1679
+ },
1680
+ children: [
1681
+ /*#__PURE__*/ _jsx(TextField, {
1682
+ multiline: true,
1683
+ minRows: 6,
1684
+ size: "small",
1685
+ label: "CSV (with header row) or JSON array",
1686
+ value: (_ref4 = importer == null ? void 0 : importer.text) != null ? _ref4 : '',
1687
+ onChange: (event)=>setImporter((prev)=>prev ? _extends({}, prev, {
1688
+ text: event.target.value
1689
+ }) : prev),
1690
+ sx: {
1691
+ mt: 1
1692
+ },
1693
+ helperText: "Columns match by field id or display name"
1694
+ }),
1695
+ /*#__PURE__*/ _jsxs(TextField, {
1696
+ select: true,
1697
+ size: "small",
1698
+ label: "Match on field (upsert)",
1699
+ value: (_ref5 = importer == null ? void 0 : importer.keyField) != null ? _ref5 : '',
1700
+ onChange: (event)=>setImporter((prev)=>prev ? _extends({}, prev, {
1701
+ keyField: event.target.value
1702
+ }) : prev),
1703
+ helperText: 'Rows whose value matches an existing record update it ' + 'instead of appending a duplicate.',
1704
+ sx: {
1705
+ maxWidth: 280
1706
+ },
1707
+ children: [
1708
+ /*#__PURE__*/ _jsx(MenuItem, {
1709
+ value: "",
1710
+ children: 'None — always append'
1711
+ }),
1712
+ model.order.map((fieldId)=>{
1713
+ var _ref;
1714
+ var _model_fields_fieldId;
1715
+ return /*#__PURE__*/ _jsx(MenuItem, {
1716
+ value: fieldId,
1717
+ children: (_ref = (_model_fields_fieldId = model.fields[fieldId]) == null ? void 0 : _model_fields_fieldId.name) != null ? _ref : fieldId
1718
+ }, fieldId);
1719
+ })
1720
+ ]
1721
+ }),
1722
+ importPreview ? /*#__PURE__*/ _jsx(Typography, {
1723
+ variant: "body2",
1724
+ color: "text.secondary",
1725
+ 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(', ')}` : '')
1726
+ }) : (importer == null ? void 0 : importer.text.trim()) ? /*#__PURE__*/ _jsx(Typography, {
1727
+ variant: "body2",
1728
+ color: "warning.main",
1729
+ children: 'Nothing parseable yet — CSV needs a header row.'
1730
+ }) : null
1731
+ ]
1732
+ }),
1733
+ /*#__PURE__*/ _jsxs(DialogActions, {
1734
+ children: [
1735
+ /*#__PURE__*/ _jsx(Button, {
1736
+ onClick: ()=>setImporter(null),
1737
+ children: 'Cancel'
1738
+ }),
1739
+ /*#__PURE__*/ _jsx(Button, {
1740
+ variant: "contained",
1741
+ color: "primary",
1742
+ disabled: !(importPreview == null ? void 0 : importPreview.valid),
1743
+ onClick: handleImport,
1744
+ children: 'Import valid rows'
1745
+ })
1746
+ ]
1747
+ })
1748
+ ]
1749
+ }),
1750
+ /*#__PURE__*/ _jsxs(Dialog, {
1751
+ open: Boolean(joiner),
1752
+ onClose: ()=>setJoiner(null),
1753
+ maxWidth: "xs",
1754
+ fullWidth: true,
1755
+ children: [
1756
+ /*#__PURE__*/ _jsx(DialogTitle, {
1757
+ children: 'New join collection'
1758
+ }),
1759
+ /*#__PURE__*/ _jsxs(DialogContent, {
1760
+ sx: {
1761
+ display: 'flex',
1762
+ flexDirection: 'column',
1763
+ gap: 2
1764
+ },
1765
+ children: [
1766
+ /*#__PURE__*/ _jsx(Typography, {
1767
+ variant: "body2",
1768
+ color: "text.secondary",
1769
+ sx: {
1770
+ mt: 1
1771
+ },
1772
+ children: 'Links two collections many-to-many; each row pairs one ' + 'document from each side.'
1773
+ }),
1774
+ [
1775
+ 'a',
1776
+ 'b'
1777
+ ].map((side)=>{
1778
+ var _ref;
1779
+ return /*#__PURE__*/ _jsx(TextField, {
1780
+ select: true,
1781
+ size: "small",
1782
+ label: side === 'a' ? 'First collection' : 'Second collection',
1783
+ value: (_ref = joiner == null ? void 0 : joiner[side]) != null ? _ref : '',
1784
+ onChange: (event)=>setJoiner((prev)=>prev ? _extends({}, prev, {
1785
+ [side]: event.target.value
1786
+ }) : prev),
1787
+ children: datasets.map((item)=>/*#__PURE__*/ _jsx(MenuItem, {
1788
+ value: item.$id,
1789
+ children: item.displayName
1790
+ }, item.$id))
1791
+ }, side);
1792
+ })
1793
+ ]
1794
+ }),
1795
+ /*#__PURE__*/ _jsxs(DialogActions, {
1796
+ children: [
1797
+ /*#__PURE__*/ _jsx(Button, {
1798
+ onClick: ()=>setJoiner(null),
1799
+ children: 'Cancel'
1800
+ }),
1801
+ /*#__PURE__*/ _jsx(Button, {
1802
+ variant: "contained",
1803
+ color: "primary",
1804
+ disabled: !scopeReady || !(joiner == null ? void 0 : joiner.a) || !(joiner == null ? void 0 : joiner.b) || joiner.a === joiner.b,
1805
+ onClick: handleCreateJoin,
1806
+ children: 'Create'
1807
+ })
1808
+ ]
1809
+ })
1810
+ ]
1811
+ }),
1812
+ /*#__PURE__*/ _jsx(DatasetSchemaDialog, {
1813
+ hostId: hostId,
1814
+ orgId: orgId != null ? orgId : undefined,
1815
+ dataset: schemaOpen && selected ? selected : null,
1816
+ datasets: datasets,
1817
+ // The dataset's documents, not the loaded window (AGL-1716) — the
1818
+ // dialog warns "N documents exist" before a narrowing type change,
1819
+ // and 500 was the cap talking, not the collection.
1820
+ recordCount: recordCount,
1821
+ seedFromCache: datasetsFromCache,
1822
+ seedUnreadable: datasetsStatus === 'error',
1823
+ onClose: ()=>setSchemaOpen(false)
1824
+ })
1825
+ ]
1826
+ });
1827
+ }
1828
+ HostDatasetsCard.displayName = 'HostDatasetsCard';
1829
+ export default HostDatasetsCard;
1830
+
1831
+ //# sourceMappingURL=host-datasets-card.component.js.map