@protobase/ui 0.19.6 → 0.19.8

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 (30) hide show
  1. package/dist/app/export/builder-port.js +54 -0
  2. package/dist/app/export/builder.js +61 -0
  3. package/dist/app/export/cells.js +93 -0
  4. package/dist/app/export/csv.js +17 -0
  5. package/dist/app/export/export-dialog.js +235 -0
  6. package/dist/app/export/export-options.js +49 -0
  7. package/dist/app/export/export-worker.d.ts +1 -0
  8. package/dist/app/export/export-worker.js +9 -0
  9. package/dist/app/export/run-export.js +62 -0
  10. package/dist/app/export/sink.js +91 -0
  11. package/dist/app/export/use-export-flow.js +139 -0
  12. package/dist/app/export/xlsx.js +91 -0
  13. package/dist/app/list-page.js +28 -3
  14. package/dist/data/query-keys.js +7 -0
  15. package/dist/live/model-helpers.js +7 -3
  16. package/dist/package.js +1 -1
  17. package/dist/shell/assets/app-D8i5r_6U.js +68 -0
  18. package/dist/shell/assets/export-worker-CVd5saLq.js +2 -0
  19. package/dist/shell/assets/index-Cuulz26c.css +2 -0
  20. package/dist/shell/assets/{index-Dqgcyg7M.js → index-cMmFPuUQ.js} +1 -1
  21. package/dist/shell/assets/maplibre-9XYrB9k6.js +814 -0
  22. package/dist/shell/assets/{record-map-m5K2uttt.js → record-map-OBtKCiUB.js} +1 -1
  23. package/dist/shell/assets/{records-map-CA_JG9RR.js → records-map-C4oMPL5C.js} +1 -1
  24. package/dist/shell/assets/{shared-protobase-ui-BBqKlVLh.js → shared-protobase-ui-CN1HmyDa.js} +1 -1
  25. package/dist/shell/index.html +4 -4
  26. package/dist/shell/protobase.shell.json +1 -1
  27. package/package.json +5 -4
  28. package/dist/shell/assets/app-DwKzLvfm.js +0 -67
  29. package/dist/shell/assets/index-CwhnZIJp.css +0 -2
  30. package/dist/shell/assets/maplibre-BWjNhvrH.js +0 -814
@@ -0,0 +1,139 @@
1
+ import { formatInt } from "../../format/number.js";
2
+ import { keys } from "../../data/query-keys.js";
3
+ import { useClient } from "../../data/api-provider.js";
4
+ import { useToast } from "../../toasts/toast-provider.js";
5
+ import { listLabels } from "../../live/relation-labels.js";
6
+ import { runExport } from "./run-export.js";
7
+ import { defaultExportColumns, exportColumns, exportHeaders, exportLimit, exportableFields, exportsSelection, selectionFilters } from "./export-options.js";
8
+ import { createWorkerPort } from "./builder-port.js";
9
+ import { ExportDialog, ExportProgressDialog, countedRows } from "./export-dialog.js";
10
+ import { canSaveAsBuilt, createDownloadSink, exportFileName, pickFileSink } from "./sink.js";
11
+ import { useRef, useState } from "react";
12
+ import { isApiError } from "@protobase/client";
13
+ import { jsx } from "react/jsx-runtime";
14
+ import { useQuery } from "@tanstack/react-query";
15
+ //#region src/app/export/use-export-flow.tsx
16
+ const defaultSink = (name, format) => canSaveAsBuilt() ? pickFileSink(name, format) : Promise.resolve(createDownloadSink(name, format));
17
+ const selectedKey = (model, row) => {
18
+ const value = row[model.primaryKey[0]];
19
+ return typeof value === "object" && value !== null && "id" in value ? value.id : value;
20
+ };
21
+ /**
22
+ * The list's Export: `open` shows its settings, with the selected rows when there are some, and `dialogs` draws them and
23
+ * the progress while the file is built. Rows come through the list API as the user may read them; the file is built in
24
+ * a worker and written as it goes.
25
+ */
26
+ const useExportFlow = ({ model, view, resources, views, plural, filter, orderBy, createPort = createWorkerPort, createSink = defaultSink }) => {
27
+ const client = useClient();
28
+ const toast = useToast();
29
+ const [stage, setStage] = useState({ kind: "closed" });
30
+ const abort = useRef(void 0);
31
+ const count = useQuery({
32
+ queryKey: keys.exportCount(model.name, filter),
33
+ enabled: stage.kind === "settings",
34
+ queryFn: async () => {
35
+ const page = await client.list(model.name, {
36
+ filter: filter || void 0,
37
+ pageSize: 1,
38
+ count: "exact",
39
+ fields: model.primaryKey
40
+ });
41
+ return page.totalSize === void 0 ? {
42
+ kind: "estimate",
43
+ rows: page.totalSizeEstimate
44
+ } : {
45
+ kind: "exact",
46
+ rows: page.totalSize
47
+ };
48
+ }
49
+ }).data ?? { kind: "counting" };
50
+ const open = (rows = []) => setStage({
51
+ kind: "settings",
52
+ selected: exportsSelection(model) ? rows.map((row) => selectedKey(model, row)) : []
53
+ });
54
+ const start = async (settings, selected) => {
55
+ const name = exportFileName(plural, settings.format);
56
+ const sink = await createSink(name, settings.format);
57
+ if (!sink) return;
58
+ const limit = exportLimit(view, settings.format);
59
+ const rows = settings.scope === "selected" ? selected.length : countedRows(count);
60
+ const total = rows === void 0 ? void 0 : Math.min(rows, limit);
61
+ const controller = new AbortController();
62
+ abort.current = controller;
63
+ setStage({
64
+ kind: "running",
65
+ done: 0,
66
+ total
67
+ });
68
+ try {
69
+ const result = await runExport({
70
+ client,
71
+ resource: model.name,
72
+ filters: settings.scope === "selected" ? selectionFilters(model, selected) : [filter],
73
+ orderBy,
74
+ plan: {
75
+ format: settings.format,
76
+ mode: settings.mode,
77
+ name: plural,
78
+ columns: exportColumns(model, view, settings.columns)
79
+ },
80
+ labels: settings.mode === "shown" ? listLabels(resources, views, model, settings.columns) : {},
81
+ limit,
82
+ port: createPort(),
83
+ sink,
84
+ signal: controller.signal,
85
+ onProgress: (done) => controller.signal.aborted || setStage({
86
+ kind: "running",
87
+ done,
88
+ total
89
+ })
90
+ });
91
+ toast.show({
92
+ state: "success",
93
+ title: `Exported ${formatInt(result.rows)} ${plural.toLowerCase()}`,
94
+ description: result.truncated ? `${name}: the first ${formatInt(result.rows)} rows, as many as a file holds.` : name
95
+ });
96
+ } catch (error) {
97
+ if (error instanceof DOMException && error.name === "AbortError") return;
98
+ if (!isApiError(error)) throw error;
99
+ toast.show({
100
+ state: "error",
101
+ title: `Could not export ${plural.toLowerCase()}`,
102
+ description: error.message
103
+ });
104
+ } finally {
105
+ abort.current = void 0;
106
+ setStage((current) => current.kind === "running" ? { kind: "closed" } : current);
107
+ }
108
+ };
109
+ const cancel = () => {
110
+ abort.current?.abort();
111
+ setStage({ kind: "closed" });
112
+ };
113
+ const fields = exportableFields(model);
114
+ const labels = exportHeaders(model, view, fields);
115
+ return {
116
+ open,
117
+ dialogs: stage.kind === "settings" ? /* @__PURE__ */ jsx(ExportDialog, {
118
+ plural,
119
+ view,
120
+ fields: fields.map((name) => ({
121
+ name,
122
+ label: labels[name]
123
+ })),
124
+ initialColumns: defaultExportColumns(model, view),
125
+ count,
126
+ selected: stage.selected.length > 0 ? stage.selected.length : void 0,
127
+ open: true,
128
+ onExport: (settings) => void start(settings, stage.selected),
129
+ onClose: () => setStage({ kind: "closed" })
130
+ }) : stage.kind === "running" ? /* @__PURE__ */ jsx(ExportProgressDialog, {
131
+ plural,
132
+ done: stage.done,
133
+ total: stage.total,
134
+ onCancel: cancel
135
+ }) : null
136
+ };
137
+ };
138
+ //#endregion
139
+ export { useExportFlow };
@@ -0,0 +1,91 @@
1
+ import { Zip, ZipDeflate } from "fflate";
2
+ //#region src/app/export/xlsx.ts
3
+ /** The most data rows a sheet holds: Excel's 1,048,576 rows, less the header. */
4
+ const xlsxMaxRows = 1048575;
5
+ const maxCellText = 32767;
6
+ const invalidXml = /[\u0000-\u0008\u000b\u000c\u000e-\u001f￾￿]/g;
7
+ const escapeXml = (text) => text.replace(invalidXml, "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
8
+ /** A column's letters: 0 is A, 25 is Z, 26 is AA. */
9
+ const columnLetters = (index) => {
10
+ const letter = String.fromCharCode(65 + index % 26);
11
+ return index < 26 ? letter : columnLetters(Math.floor(index / 26) - 1) + letter;
12
+ };
13
+ const textCell = (ref, text, style = "") => `<c r="${ref}"${style} t="inlineStr"><is><t xml:space="preserve">${escapeXml(text.slice(0, maxCellText))}</t></is></c>`;
14
+ const xml = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n";
15
+ const mainNs = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
16
+ const relNs = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
17
+ const packageRelNs = "http://schemas.openxmlformats.org/package/2006/relationships";
18
+ const contentTypes = `${xml}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>`;
19
+ const rootRels = `${xml}<Relationships xmlns="${packageRelNs}"><Relationship Id="rId1" Type="${relNs}/officeDocument" Target="xl/workbook.xml"/></Relationships>`;
20
+ const workbookRels = `${xml}<Relationships xmlns="${packageRelNs}"><Relationship Id="rId1" Type="${relNs}/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="${relNs}/styles" Target="styles.xml"/></Relationships>`;
21
+ const sheetName = (name) => name.replace(/[:\\/?*[\]]/g, " ").slice(0, 31).trim() || "Sheet1";
22
+ const workbook = (name) => `${xml}<workbook xmlns="${mainNs}" xmlns:r="${relNs}"><sheets><sheet name="${escapeXml(sheetName(name))}" sheetId="1" r:id="rId1"/></sheets></workbook>`;
23
+ const styles = (formats) => {
24
+ const numFmts = formats.map((code, index) => `<numFmt numFmtId="${164 + index}" formatCode="${escapeXml(code)}"/>`).join("");
25
+ const xfs = formats.map((_, index) => `<xf numFmtId="${164 + index}" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>`).join("");
26
+ return `${xml}<styleSheet xmlns="${mainNs}">${formats.length > 0 ? `<numFmts count="${formats.length}">${numFmts}</numFmts>` : ""}<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="${2 + formats.length}"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/>${xfs}</cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>`;
27
+ };
28
+ const encoder = new TextEncoder();
29
+ /**
30
+ * Writes a one-sheet .xlsx file as it goes, handing each compressed piece to `onChunk`: a bold, frozen header row, then
31
+ * `rows` as often as they come, and `finish` once. Text is written inline in its cell rather than in a table of shared
32
+ * strings, so the writer holds no more than the rows it is given.
33
+ */
34
+ const createXlsxWriter = (sheet, onChunk) => {
35
+ const zip = new Zip((error, chunk, final) => {
36
+ if (error) throw error;
37
+ onChunk(chunk, final);
38
+ });
39
+ const add = (path, text) => {
40
+ const file = new ZipDeflate(path, { level: 6 });
41
+ zip.add(file);
42
+ file.push(encoder.encode(text), true);
43
+ };
44
+ add("[Content_Types].xml", contentTypes);
45
+ add("_rels/.rels", rootRels);
46
+ add("xl/workbook.xml", workbook(sheet.name));
47
+ add("xl/_rels/workbook.xml.rels", workbookRels);
48
+ const worksheet = new ZipDeflate("xl/worksheets/sheet1.xml", { level: 6 });
49
+ zip.add(worksheet);
50
+ const cols = sheet.widths.map((width, index) => `<col min="${index + 1}" max="${index + 1}" width="${width}" customWidth="1"/>`).join("");
51
+ const frozen = "<sheetViews><sheetView workbookViewId=\"0\"><pane ySplit=\"1\" topLeftCell=\"A2\" activePane=\"bottomLeft\" state=\"frozen\"/></sheetView></sheetViews>";
52
+ const header = `<row r="1">${sheet.headers.map((text, index) => textCell(`${columnLetters(index)}1`, text, " s=\"1\"")).join("")}</row>`;
53
+ worksheet.push(encoder.encode(`${xml}<worksheet xmlns="${mainNs}">${frozen}${cols ? `<cols>${cols}</cols>` : ""}<sheetData>${header}`));
54
+ const letters = sheet.headers.map((_, index) => columnLetters(index));
55
+ const formats = [];
56
+ const styleOf = (format) => {
57
+ const index = formats.indexOf(format);
58
+ if (index >= 0) return index + 2;
59
+ formats.push(format);
60
+ return formats.length + 1;
61
+ };
62
+ const cellXml = (cell, ref) => {
63
+ switch (cell.kind) {
64
+ case "empty": return "";
65
+ case "text": return textCell(ref, cell.value);
66
+ case "boolean": return `<c r="${ref}" t="b"><v>${cell.value ? 1 : 0}</v></c>`;
67
+ case "number": return `<c r="${ref}"${cell.format ? ` s="${styleOf(cell.format)}"` : ""}><v>${cell.value}</v></c>`;
68
+ }
69
+ };
70
+ let rowNumber = 1;
71
+ const rows = (cells) => {
72
+ let text = "";
73
+ for (const row of cells) {
74
+ rowNumber += 1;
75
+ if (rowNumber > 1048576) throw new Error(`A sheet holds at most ${xlsxMaxRows} rows`);
76
+ text += `<row r="${rowNumber}">${row.map((cell, index) => cellXml(cell, `${letters[index]}${rowNumber}`)).join("")}</row>`;
77
+ }
78
+ worksheet.push(encoder.encode(text));
79
+ };
80
+ const finish = () => {
81
+ worksheet.push(encoder.encode("</sheetData></worksheet>"), true);
82
+ add("xl/styles.xml", styles(formats));
83
+ zip.end();
84
+ };
85
+ return {
86
+ rows,
87
+ finish
88
+ };
89
+ };
90
+ //#endregion
91
+ export { columnLetters, createXlsxWriter, xlsxMaxRows };
@@ -29,6 +29,8 @@ import { Sheet } from "../primitives/sheet.js";
29
29
  import { useDeleteFlow } from "./delete/use-delete-flow.js";
30
30
  import { useRestore } from "./delete/use-restore.js";
31
31
  import { ImportButton } from "./import/import-button.js";
32
+ import { exportOffered, exportsSelection } from "./export/export-options.js";
33
+ import { useExportFlow } from "./export/use-export-flow.js";
32
34
  import { SignInPrompt } from "../auth/sign-in-prompt.js";
33
35
  import { ListCards } from "./displays/list-cards.js";
34
36
  import { MapDisplay } from "./displays/map-display.js";
@@ -44,7 +46,7 @@ import { facetNames, filterChips } from "./searches/saved-search.js";
44
46
  import { SaveSearchButton } from "./searches/save-search-button.js";
45
47
  import { SavedSearches } from "./searches/saved-searches.js";
46
48
  import { useMemo, useState } from "react";
47
- import { Plus, SlidersHorizontal } from "lucide-react";
49
+ import { Download, Plus, SlidersHorizontal } from "lucide-react";
48
50
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
49
51
  import { createWhere } from "@protobase/schema";
50
52
  import { tableDisplay } from "@protobase/schema/internal";
@@ -272,6 +274,23 @@ const LoadedList = ({ model, address, scope, heading }) => {
272
274
  chips: filterChips(model, view, filterText, facetNames(config))
273
275
  });
274
276
  const importButton = !scope && /* @__PURE__ */ jsx(ImportButton, { model });
277
+ const exportFlow = useExportFlow({
278
+ model,
279
+ view,
280
+ resources: meta.resources,
281
+ views: meta.views,
282
+ plural,
283
+ filter: loadFilter,
284
+ orderBy
285
+ });
286
+ const exportButton = exportOffered(view) && /* @__PURE__ */ jsxs(Button, {
287
+ onClick: () => exportFlow.open(),
288
+ ...!desktop && {
289
+ "aria-label": "Export",
290
+ title: "Export"
291
+ },
292
+ children: [/* @__PURE__ */ jsx(Download, { className: "size-3.5" }), desktop && "Export"]
293
+ });
275
294
  const displaySwitch = (menu) => /* @__PURE__ */ jsx(DisplaySwitch, {
276
295
  view,
277
296
  value: display?.name ?? tableDisplay,
@@ -300,11 +319,14 @@ const LoadedList = ({ model, address, scope, heading }) => {
300
319
  available: (row) => rowAllows(row, "delete"),
301
320
  onSelect: (row) => void remove.start(target(row))
302
321
  }] : []],
303
- bulkActions: can(permissions, "delete") ? [{
322
+ bulkActions: [...exportOffered(view) && exportsSelection(model) ? [{
323
+ label: (count) => `Export ${count}`,
324
+ onRun: (rows) => exportFlow.open(rows)
325
+ }] : [], ...can(permissions, "delete") ? [{
304
326
  label: (count) => `Delete ${count}`,
305
327
  destructive: true,
306
328
  onRun: (rows) => remove.startBulk(rows.map(target))
307
- }] : [],
329
+ }] : []],
308
330
  rowNumbers: false,
309
331
  pinKey: auth.kind === "signed-in" ? pinnedStorageKey(auth.user.id, resource) : void 0,
310
332
  scroll: "parent",
@@ -344,6 +366,7 @@ const LoadedList = ({ model, address, scope, heading }) => {
344
366
  className: "flex min-h-0 flex-1 flex-col gap-3 px-5 py-4",
345
367
  children: [
346
368
  remove.dialogs,
369
+ exportFlow.dialogs,
347
370
  /* @__PURE__ */ jsx(PageHeader, {
348
371
  title: heading?.title ?? plural,
349
372
  subtitle: heading ? heading.subtitle : view?.help,
@@ -352,10 +375,12 @@ const LoadedList = ({ model, address, scope, heading }) => {
352
375
  displaySwitch(false),
353
376
  saveSearch,
354
377
  importButton,
378
+ exportButton,
355
379
  createButton
356
380
  ] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
357
381
  saveSearch,
358
382
  importButton,
383
+ exportButton,
359
384
  createButton
360
385
  ] })
361
386
  }),
@@ -90,6 +90,13 @@ const keys = {
90
90
  resource,
91
91
  filter
92
92
  ],
93
+ /** How many rows an export of the list would hold, exactly up to 10,000; starts with `list`, so it refreshes with the list. */
94
+ exportCount: (resource, filter) => [
95
+ "list",
96
+ resource,
97
+ filter,
98
+ "export-count"
99
+ ],
93
100
  firstRecord: (resource, filter, orderBy) => [
94
101
  "first-record",
95
102
  resource,
@@ -20,10 +20,14 @@ const recordTitle = (model, view, record, fallback) => {
20
20
  if (value === null || value === void 0) return fallback;
21
21
  return model.fields[name]?.type === "enum" ? enumLabel(String(value), view?.fields[name]?.valueLabels) : String(value);
22
22
  };
23
- const recordId = (model, record) => model.primaryKey.map((name) => String(record[name])).join(",");
24
- /** The key as the URL carries it, for `get` and `update`. */
23
+ const keyPart = (model, record, name) => {
24
+ const value = record[name];
25
+ return model.fields[name]?.type === "relation" && typeof value === "object" && value !== null && "id" in value ? value.id : value;
26
+ };
27
+ const recordId = (model, record) => model.primaryKey.map((name) => String(keyPart(model, record, name))).join(",");
28
+ /** The key as the URL carries it, for `get` and `update`, from a stored record or a list row. */
25
29
  const recordKey = (model, record) => {
26
- const parts = model.primaryKey.map((name) => record[name]);
30
+ const parts = model.primaryKey.map((name) => keyPart(model, record, name));
27
31
  return parts.length === 1 ? parts[0] : parts;
28
32
  };
29
33
  //#endregion
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.19.6";
2
+ var version = "0.19.8";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };