@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.
- package/LICENSE +201 -0
- package/README.md +7 -0
- package/package.json +47 -0
- package/src/index.d.ts +19 -0
- package/src/index.js +20 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/data-console-page.d.ts +13 -0
- package/src/lib/components/data-console-page.js +63 -0
- package/src/lib/components/data-console-page.js.map +1 -0
- package/src/lib/components/dataset-record-dialog.component.d.ts +52 -0
- package/src/lib/components/dataset-record-dialog.component.js +249 -0
- package/src/lib/components/dataset-record-dialog.component.js.map +1 -0
- package/src/lib/components/dataset-schema-dialog.component.d.ts +51 -0
- package/src/lib/components/dataset-schema-dialog.component.js +979 -0
- package/src/lib/components/dataset-schema-dialog.component.js.map +1 -0
- package/src/lib/components/host-datasets-card.component.d.ts +39 -0
- package/src/lib/components/host-datasets-card.component.js +1831 -0
- package/src/lib/components/host-datasets-card.component.js.map +1 -0
- package/src/lib/constants/bundle-common.d.ts +8 -0
- package/src/lib/constants/bundle-common.js +9 -0
- package/src/lib/constants/bundle-common.js.map +1 -0
- package/src/lib/model/dataset-io.d.ts +43 -0
- package/src/lib/model/dataset-io.js +84 -0
- package/src/lib/model/dataset-io.js.map +1 -0
- package/src/lib/model/dataset-record-view.d.ts +157 -0
- package/src/lib/model/dataset-record-view.js +293 -0
- package/src/lib/model/dataset-record-view.js.map +1 -0
- package/src/lib/model/index.d.ts +22 -0
- package/src/lib/model/index.js +22 -0
- package/src/lib/model/index.js.map +1 -0
- package/src/lib/plugin.d.ts +35 -0
- package/src/lib/plugin.js +78 -0
- package/src/lib/plugin.js.map +1 -0
- package/src/lib/repeat/dataset-repeat-rows.d.ts +45 -0
- package/src/lib/repeat/dataset-repeat-rows.js +108 -0
- package/src/lib/repeat/dataset-repeat-rows.js.map +1 -0
- package/src/lib/repeat/dataset-repeat-source.d.ts +39 -0
- package/src/lib/repeat/dataset-repeat-source.js +95 -0
- 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
|