@protobase/ui 0.19.5 → 0.19.7

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 (144) hide show
  1. package/dist/account/account-section.js +2 -2
  2. package/dist/account/account-security-page.js +2 -2
  3. package/dist/account/backup-codes.js +1 -1
  4. package/dist/account/passkeys-section.js +2 -2
  5. package/dist/account/password-dialog.js +1 -1
  6. package/dist/account/sign-in-policy-form.js +2 -2
  7. package/dist/account/sign-in-policy-page.js +2 -2
  8. package/dist/account/sign-in-providers-section.js +1 -1
  9. package/dist/account/staff-sign-in-log.js +3 -3
  10. package/dist/account/two-factor-setup.js +6 -6
  11. package/dist/action-card/action-card.js +1 -1
  12. package/dist/activity-chart/activity-chart.js +2 -2
  13. package/dist/app/delete/dialogs.js +3 -3
  14. package/dist/app/displays/display-controls.js +1 -1
  15. package/dist/app/displays/display-frame.js +1 -1
  16. package/dist/app/displays/list-cards.js +1 -1
  17. package/dist/app/displays/map-display.js +1 -1
  18. package/dist/app/displays/record-card.js +1 -1
  19. package/dist/app/embedded-list/embedded-list.js +2 -2
  20. package/dist/app/error-banner.js +1 -1
  21. package/dist/app/export/builder-port.js +54 -0
  22. package/dist/app/export/builder.js +61 -0
  23. package/dist/app/export/cells.js +93 -0
  24. package/dist/app/export/csv.js +17 -0
  25. package/dist/app/export/export-dialog.js +235 -0
  26. package/dist/app/export/export-options.js +49 -0
  27. package/dist/app/export/export-worker.d.ts +1 -0
  28. package/dist/app/export/export-worker.js +9 -0
  29. package/dist/app/export/run-export.js +62 -0
  30. package/dist/app/export/sink.js +91 -0
  31. package/dist/app/export/use-export-flow.js +139 -0
  32. package/dist/app/export/xlsx.js +91 -0
  33. package/dist/app/field-editor.js +6 -12
  34. package/dist/app/file-field.js +4 -4
  35. package/dist/app/gallery/lightbox.js +2 -2
  36. package/dist/app/gallery/photo-details.js +1 -1
  37. package/dist/app/import/import-check-step.js +2 -2
  38. package/dist/app/import/import-file-step.js +2 -2
  39. package/dist/app/import/import-mapping-step.js +1 -1
  40. package/dist/app/import/import-result-step.js +2 -2
  41. package/dist/app/import/import-select.js +2 -2
  42. package/dist/app/list-page.js +28 -3
  43. package/dist/app/meta-gate.js +3 -3
  44. package/dist/app/pages/blocks/card-frame.js +4 -4
  45. package/dist/app/pages/blocks/field.js +2 -5
  46. package/dist/app/pages/blocks/stat.js +2 -2
  47. package/dist/app/pages/problem.js +1 -1
  48. package/dist/app/pages/render-node.js +1 -1
  49. package/dist/app/record-page.js +2 -2
  50. package/dist/app/record-row/record-row-body.js +3 -3
  51. package/dist/app/record-row/saved-record-row.js +1 -1
  52. package/dist/app/related-section.js +1 -1
  53. package/dist/app/relation-picker.js +1 -1
  54. package/dist/app/runtime/runtime-update.js +3 -3
  55. package/dist/app/saved/saved-list.js +1 -1
  56. package/dist/app/searches/saved-search-rows.js +2 -2
  57. package/dist/app/searches/saved-search-section.js +1 -1
  58. package/dist/app/searches/saved-searches.js +1 -1
  59. package/dist/app/searches/search-controls.js +2 -2
  60. package/dist/app/searches/search-name-dialog.js +1 -1
  61. package/dist/app/searches/update-search-dialog.js +16 -19
  62. package/dist/app/sensitive-field.js +1 -1
  63. package/dist/app/source-notice.js +3 -3
  64. package/dist/app/staff-banner.js +1 -1
  65. package/dist/app-shell/breadcrumb.js +1 -1
  66. package/dist/app-shell/global-search.js +1 -1
  67. package/dist/app-shell/page-header.js +1 -1
  68. package/dist/app-shell/profile-menu.js +3 -3
  69. package/dist/app-shell/sidebar-group.js +1 -1
  70. package/dist/app-shell/sidebar-item.js +1 -1
  71. package/dist/app-shell/sidebar.js +2 -2
  72. package/dist/assistant-dock/part-view.js +1 -4
  73. package/dist/assistant-dock/widget-view.js +1 -4
  74. package/dist/auth/auth-gate.js +2 -2
  75. package/dist/auth/auth-layout.js +1 -1
  76. package/dist/auth/code-form.js +2 -2
  77. package/dist/auth/first-run-page.js +1 -1
  78. package/dist/auth/forgot-password-form.js +2 -2
  79. package/dist/auth/provider-sign-in-page.js +1 -1
  80. package/dist/auth/reset-password-form.js +3 -3
  81. package/dist/auth/setup-required-form.js +2 -2
  82. package/dist/auth/setup-required-page.js +1 -1
  83. package/dist/auth/sign-in-form.js +5 -5
  84. package/dist/auth/sign-in-prompt.js +2 -2
  85. package/dist/auth/sign-up-details-form.js +3 -3
  86. package/dist/auth/sign-up-email-form.js +4 -4
  87. package/dist/auth/staff-sign-in-form.js +3 -3
  88. package/dist/auth/two-factor-form.js +5 -5
  89. package/dist/chat/chat-message.js +1 -1
  90. package/dist/chat/chat-thread.js +1 -1
  91. package/dist/data/query-keys.js +7 -0
  92. package/dist/data-grid/grid-row.js +1 -1
  93. package/dist/data-grid/header-menu.js +1 -1
  94. package/dist/data-grid/row-menu.js +1 -1
  95. package/dist/filter-panel/facet-combobox.js +1 -1
  96. package/dist/filter-panel/filter-menu.js +1 -1
  97. package/dist/filter-panel/filter-panel.js +2 -2
  98. package/dist/inline-child-table/line-menu.js +1 -1
  99. package/dist/inline-child-table/line-row.js +4 -4
  100. package/dist/inline-child-table/number-cell.js +1 -1
  101. package/dist/inline-child-table/totals-footer.js +2 -2
  102. package/dist/organizations/app-roles-picker.js +1 -1
  103. package/dist/organizations/invitation-page.js +3 -3
  104. package/dist/organizations/invitations-page.js +2 -2
  105. package/dist/organizations/members-page.js +2 -2
  106. package/dist/organizations/organization-page.js +1 -1
  107. package/dist/organizations/organizations-page.js +2 -2
  108. package/dist/package.js +1 -1
  109. package/dist/plugins/before-sign-in.js +1 -1
  110. package/dist/plugins/plugin-gate.js +1 -1
  111. package/dist/plugins/plugin-notice.js +1 -1
  112. package/dist/primitives/button.js +1 -1
  113. package/dist/primitives/dialog.js +2 -2
  114. package/dist/primitives/input.js +1 -1
  115. package/dist/primitives/menu-select.js +2 -2
  116. package/dist/primitives/responsive-dialog.js +1 -1
  117. package/dist/primitives/segmented.js +1 -1
  118. package/dist/primitives/sheet.js +1 -1
  119. package/dist/primitives/textarea.js +1 -1
  120. package/dist/record-view/action-rail.js +1 -1
  121. package/dist/record-view/field.js +1 -1
  122. package/dist/record-view/key-facts.js +2 -2
  123. package/dist/record-view/record-sidebar.js +2 -2
  124. package/dist/record-view/section.js +2 -2
  125. package/dist/record-view/spec-table.js +1 -1
  126. package/dist/shell/assets/app-BRVJlz_E.js +68 -0
  127. package/dist/shell/assets/export-worker-CVd5saLq.js +2 -0
  128. package/dist/shell/assets/{index-BzCCIfII.js → index-CafsxSWx.js} +1 -1
  129. package/dist/shell/assets/index-Cuulz26c.css +2 -0
  130. package/dist/shell/assets/maplibre-Cb7ks8SG.js +814 -0
  131. package/dist/shell/assets/{record-map-BK_02Lmk.js → record-map-dXyPrmGg.js} +1 -1
  132. package/dist/shell/assets/{records-map-Dn72A_l-.js → records-map-bGH18FRn.js} +1 -1
  133. package/dist/shell/assets/{shared-protobase-ui-B6lrj7eA.js → shared-protobase-ui-e-G14mxb.js} +1 -1
  134. package/dist/shell/index.html +4 -4
  135. package/dist/shell/protobase.shell.json +1 -1
  136. package/dist/side-panel/side-panel.js +1 -1
  137. package/dist/step-list/step-list.js +1 -1
  138. package/dist/styles.css +1 -1
  139. package/dist/theme.css +2 -0
  140. package/dist/toasts/toast.js +2 -2
  141. package/package.json +5 -4
  142. package/dist/shell/assets/app-CZ4drn7G.js +0 -67
  143. package/dist/shell/assets/index-BoeKjvo8.css +0 -2
  144. package/dist/shell/assets/maplibre-BOsAtlAo.js +0 -814
@@ -0,0 +1,235 @@
1
+ import { Segmented } from "../../primitives/segmented.js";
2
+ import { formatApproxRows, formatInt } from "../../format/number.js";
3
+ import { Button } from "../../primitives/button.js";
4
+ import { Checkbox } from "../../primitives/checkbox.js";
5
+ import { ResponsiveDialog } from "../../primitives/responsive-dialog.js";
6
+ import { exportLimit } from "./export-options.js";
7
+ import { useState } from "react";
8
+ import { ArrowDown, ArrowUp } from "lucide-react";
9
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
10
+ //#region src/app/export/export-dialog.tsx
11
+ const formatOptions = [{
12
+ value: "xlsx",
13
+ label: "Excel (.xlsx)"
14
+ }, {
15
+ value: "csv",
16
+ label: "CSV"
17
+ }];
18
+ const modeOptions = [{
19
+ value: "shown",
20
+ label: "As shown"
21
+ }, {
22
+ value: "raw",
23
+ label: "Raw values"
24
+ }];
25
+ const modeHelp = {
26
+ shown: "Labels, dates and numbers as the list shows them.",
27
+ raw: "Values as they are stored, such as keys and codes."
28
+ };
29
+ const countText = (count) => {
30
+ if (count.kind === "counting") return "Counting rows…";
31
+ if (count.kind === "exact") return formatApproxRows(count.rows);
32
+ return count.rows > 1e4 ? formatApproxRows(count.rows) : "more than 10,000 rows";
33
+ };
34
+ /** The fewest rows the list finds, as far as the count tells: an estimate stands only above 10,000. */
35
+ const countedRows = (count) => {
36
+ if (count.kind === "counting") return void 0;
37
+ return count.kind === "exact" ? count.rows : Math.max(count.rows, 10001);
38
+ };
39
+ const Setting = ({ label, help, children }) => /* @__PURE__ */ jsxs("div", {
40
+ className: "flex flex-col items-start gap-1.5",
41
+ children: [
42
+ /* @__PURE__ */ jsx("span", {
43
+ className: "text-[13px] font-medium",
44
+ children: label
45
+ }),
46
+ children,
47
+ help && /* @__PURE__ */ jsx("span", {
48
+ className: "text-xs text-muted-foreground",
49
+ children: help
50
+ })
51
+ ]
52
+ });
53
+ const move = (columns, name, by) => {
54
+ const index = columns.indexOf(name);
55
+ const next = [...columns];
56
+ next.splice(index, 1);
57
+ next.splice(index + by, 0, name);
58
+ return next;
59
+ };
60
+ /**
61
+ * The columns a file holds, in order: the chosen ones first, each with buttons that move it up or down, then the
62
+ * fields left out, to add. The dialog scrolls when the list is long, so it has no scroll box of its own.
63
+ */
64
+ const ColumnPicker = ({ fields, columns, onChange }) => {
65
+ const labels = Object.fromEntries(fields.map((field) => [field.name, field.label]));
66
+ const ordered = [...columns, ...fields.map((field) => field.name).filter((name) => !columns.includes(name))];
67
+ return /* @__PURE__ */ jsx("ul", {
68
+ className: "divide-y self-stretch rounded-md border",
69
+ "aria-label": "Columns",
70
+ children: ordered.map((name) => {
71
+ const chosen = columns.includes(name);
72
+ const index = columns.indexOf(name);
73
+ return /* @__PURE__ */ jsxs("li", {
74
+ className: "flex min-h-10 items-center gap-2 px-3 py-1",
75
+ children: [/* @__PURE__ */ jsxs("label", {
76
+ className: "flex min-w-0 flex-1 cursor-pointer items-center gap-2.5",
77
+ children: [/* @__PURE__ */ jsx(Checkbox, {
78
+ checked: chosen,
79
+ label: labels[name],
80
+ onChange: (checked) => onChange(checked ? [...columns, name] : columns.filter((column) => column !== name))
81
+ }), /* @__PURE__ */ jsx("span", {
82
+ className: chosen ? "truncate" : "truncate text-muted-foreground",
83
+ children: labels[name]
84
+ })]
85
+ }), chosen && /* @__PURE__ */ jsxs("span", {
86
+ className: "flex shrink-0 gap-1",
87
+ children: [/* @__PURE__ */ jsx(Button, {
88
+ size: "sm",
89
+ variant: "ghost",
90
+ className: "min-h-8 px-2",
91
+ "aria-label": `Move ${labels[name]} up`,
92
+ disabled: index === 0,
93
+ onClick: () => onChange(move(columns, name, -1)),
94
+ children: /* @__PURE__ */ jsx(ArrowUp, { className: "size-3.5" })
95
+ }), /* @__PURE__ */ jsx(Button, {
96
+ size: "sm",
97
+ variant: "ghost",
98
+ className: "min-h-8 px-2",
99
+ "aria-label": `Move ${labels[name]} down`,
100
+ disabled: index === columns.length - 1,
101
+ onClick: () => onChange(move(columns, name, 1)),
102
+ children: /* @__PURE__ */ jsx(ArrowDown, { className: "size-3.5" })
103
+ })]
104
+ })]
105
+ }, name);
106
+ })
107
+ });
108
+ };
109
+ /**
110
+ * The Export dialog: the file's format, which rows (the list's, or the selected ones), which columns in which order, and
111
+ * whether values are exported as shown or raw, with the number of rows before it starts.
112
+ */
113
+ const ExportDialog = ({ plural, view, fields, initialColumns, count, selected, open, onExport, onClose }) => {
114
+ const [format, setFormat] = useState("xlsx");
115
+ const [scope, setScope] = useState(selected ? "selected" : "list");
116
+ const [columns, setColumns] = useState(initialColumns);
117
+ const [mode, setMode] = useState("shown");
118
+ const limit = exportLimit(view, format);
119
+ const rows = scope === "selected" ? `${formatInt(selected ?? 0)} selected ${selected === 1 ? "row" : "rows"}` : countText(count);
120
+ const overLimit = (scope === "selected" ? selected ?? 0 : countedRows(count) ?? 0) > limit;
121
+ const scopeOptions = [{
122
+ value: "list",
123
+ label: "Rows in the list"
124
+ }, {
125
+ value: "selected",
126
+ label: `Selected (${formatInt(selected ?? 0)})`
127
+ }];
128
+ return /* @__PURE__ */ jsx(ResponsiveDialog, {
129
+ title: `Export ${plural.toLowerCase()}`,
130
+ open,
131
+ onClose,
132
+ actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
133
+ onClick: onClose,
134
+ children: "Cancel"
135
+ }), /* @__PURE__ */ jsx(Button, {
136
+ variant: "primary",
137
+ disabled: columns.length === 0,
138
+ onClick: () => onExport({
139
+ format,
140
+ scope,
141
+ columns,
142
+ mode
143
+ }),
144
+ children: "Export"
145
+ })] }),
146
+ children: /* @__PURE__ */ jsxs("div", {
147
+ className: "flex flex-col gap-4 text-[13px]",
148
+ children: [
149
+ /* @__PURE__ */ jsx(Setting, {
150
+ label: "Format",
151
+ children: /* @__PURE__ */ jsx(Segmented, {
152
+ size: "md",
153
+ label: "Format",
154
+ value: format,
155
+ options: formatOptions,
156
+ onChange: setFormat
157
+ })
158
+ }),
159
+ selected !== void 0 && selected > 0 && /* @__PURE__ */ jsx(Setting, {
160
+ label: "Rows",
161
+ help: scope === "list" ? "With the list’s filters, search and sort." : void 0,
162
+ children: /* @__PURE__ */ jsx(Segmented, {
163
+ size: "md",
164
+ label: "Rows",
165
+ value: scope,
166
+ options: scopeOptions,
167
+ onChange: setScope
168
+ })
169
+ }),
170
+ /* @__PURE__ */ jsx(Setting, {
171
+ label: "Values",
172
+ help: modeHelp[mode],
173
+ children: /* @__PURE__ */ jsx(Segmented, {
174
+ size: "md",
175
+ label: "Values",
176
+ value: mode,
177
+ options: modeOptions,
178
+ onChange: setMode
179
+ })
180
+ }),
181
+ /* @__PURE__ */ jsx(Setting, {
182
+ label: "Columns",
183
+ children: /* @__PURE__ */ jsx(ColumnPicker, {
184
+ fields,
185
+ columns,
186
+ onChange: setColumns
187
+ })
188
+ }),
189
+ /* @__PURE__ */ jsxs("p", {
190
+ role: "status",
191
+ className: "text-muted-foreground",
192
+ children: [
193
+ "Exports ",
194
+ rows,
195
+ ".",
196
+ overLimit && ` A file holds at most ${formatInt(limit)} rows; the rest are left out.`
197
+ ]
198
+ })
199
+ ]
200
+ })
201
+ });
202
+ };
203
+ /** While the file is built: how many rows are in it so far, out of how many, and a way to stop. */
204
+ const ExportProgressDialog = ({ plural, done, total, onCancel }) => {
205
+ const share = total ? Math.min(1, done / total) : void 0;
206
+ return /* @__PURE__ */ jsx(ResponsiveDialog, {
207
+ title: `Exporting ${plural.toLowerCase()}`,
208
+ open: true,
209
+ onClose: onCancel,
210
+ actions: /* @__PURE__ */ jsx(Button, {
211
+ onClick: onCancel,
212
+ children: "Cancel"
213
+ }),
214
+ children: /* @__PURE__ */ jsxs("div", {
215
+ className: "flex flex-col gap-2 text-[13px]",
216
+ children: [/* @__PURE__ */ jsx("div", {
217
+ role: "progressbar",
218
+ "aria-label": "Rows exported",
219
+ "aria-valuemin": 0,
220
+ "aria-valuemax": total,
221
+ "aria-valuenow": done,
222
+ className: "h-1.5 overflow-hidden rounded-full bg-muted",
223
+ children: /* @__PURE__ */ jsx("div", {
224
+ className: "h-full rounded-full bg-primary transition-[width]",
225
+ style: { width: `${(share ?? 0) * 100}%` }
226
+ })
227
+ }), /* @__PURE__ */ jsx("p", {
228
+ className: "text-muted-foreground",
229
+ children: total ? `${formatInt(done)} of ${formatInt(total)} rows` : `${formatInt(done)} rows`
230
+ })]
231
+ })
232
+ });
233
+ };
234
+ //#endregion
235
+ export { ExportDialog, ExportProgressDialog, countedRows };
@@ -0,0 +1,49 @@
1
+ import { columnsFromModel, listColumnNames } from "../../live/columns-from-model.js";
2
+ import { xlsxMaxRows } from "./xlsx.js";
3
+ import { createWhere, printFilter } from "@protobase/schema";
4
+ //#region src/app/export/export-options.ts
5
+ const where = createWhere();
6
+ /** Whether the view's list offers Export: unless the view takes it away. */
7
+ const exportOffered = (view) => view?.export !== false;
8
+ /**
9
+ * The fields a file may hold, in the resource's order: those the user may read, which `/meta` gives, but the sensitive
10
+ * ones, which no list carries and the page shows only when revealed one at a time.
11
+ */
12
+ const exportableFields = (model) => Object.values(model.fields).flatMap((field) => field.sensitive ? [] : [field.name]);
13
+ /** The columns a file starts with: the list's own, in its order. */
14
+ const defaultExportColumns = (model, view) => {
15
+ const exportable = new Set(exportableFields(model));
16
+ return listColumnNames(model, view).filter((name) => exportable.has(name));
17
+ };
18
+ /** Each field's header, as the list's column for it reads. */
19
+ const exportHeaders = (model, view, names) => Object.fromEntries(columnsFromModel(model, view, [], names).map((column) => [column.id, column.header]));
20
+ /** The columns of a file, each with its field, header and the view's hints for showing it. */
21
+ const exportColumns = (model, view, names) => {
22
+ const headers = exportHeaders(model, view, names);
23
+ return names.flatMap((name) => {
24
+ const field = model.fields[name];
25
+ return field ? [{
26
+ name,
27
+ header: headers[name],
28
+ field,
29
+ hints: view?.fields[name] ?? {}
30
+ }] : [];
31
+ });
32
+ };
33
+ /** The most rows a file of `format` holds: the view's limit, and for a workbook what one sheet can hold. */
34
+ const exportLimit = (view, format) => {
35
+ const viewLimit = view?.export ? view.export.maxRows ?? Infinity : Infinity;
36
+ return Math.min(viewLimit, format === "xlsx" ? xlsxMaxRows : Infinity);
37
+ };
38
+ /** Whether selected rows can be exported: they are found again by their key, which must be a single field. */
39
+ const exportsSelection = (model) => model.primaryKey.length === 1;
40
+ /** Filters that find the selected records again by key, a page's worth in each. */
41
+ const selectionFilters = (model, keys) => {
42
+ const [keyField] = model.primaryKey;
43
+ if (!keyField || model.primaryKey.length !== 1) throw new Error("Not implemented: selected rows are exported by a single-field key");
44
+ const filters = [];
45
+ for (let start = 0; start < keys.length; start += 500) filters.push(printFilter(where.in(keyField, keys.slice(start, start + 500))));
46
+ return filters;
47
+ };
48
+ //#endregion
49
+ export { defaultExportColumns, exportColumns, exportHeaders, exportLimit, exportOffered, exportableFields, exportsSelection, selectionFilters };
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1,9 @@
1
+ import { createBuilderHandler } from "./builder-port.js";
2
+ //#region src/app/export/export-worker.ts
3
+ const handle = createBuilderHandler();
4
+ const scope = self;
5
+ scope.onmessage = (event) => {
6
+ const bytes = handle(event.data);
7
+ scope.postMessage({ bytes }, [bytes.buffer]);
8
+ };
9
+ //#endregion
@@ -0,0 +1,62 @@
1
+ const aborted = () => new DOMException("The export was cancelled", "AbortError");
2
+ /**
3
+ * Reads the rows through the list API, page by page with its page tokens, and has the port build the file from each;
4
+ * each piece is written to the sink as it is built, so only a page is held at a time. A cancel stops at the next page
5
+ * and discards what was written.
6
+ */
7
+ const runExport = async ({ client, resource, filters, orderBy, plan, labels, limit, port, sink, signal, onProgress }) => {
8
+ const fields = plan.columns.map((column) => column.name);
9
+ let rows = 0;
10
+ try {
11
+ await sink.write(await port.send({
12
+ kind: "start",
13
+ plan
14
+ }));
15
+ for (const [index, filter] of filters.entries()) {
16
+ let pageToken = "";
17
+ do {
18
+ const page = await client.search(resource, {
19
+ filter,
20
+ orderBy,
21
+ pageSize: 500,
22
+ pageToken,
23
+ fields,
24
+ labels
25
+ });
26
+ if (signal.aborted) throw aborted();
27
+ const records = page.items.slice(0, limit - rows);
28
+ await sink.write(await port.send({
29
+ kind: "page",
30
+ records,
31
+ labels: page.labels ?? {}
32
+ }));
33
+ rows += records.length;
34
+ onProgress(rows);
35
+ pageToken = page.nextPageToken;
36
+ if (rows >= limit) {
37
+ await finish(port, sink);
38
+ return {
39
+ rows,
40
+ truncated: records.length < page.items.length || pageToken !== "" || index + 1 < filters.length
41
+ };
42
+ }
43
+ } while (pageToken !== "");
44
+ }
45
+ await finish(port, sink);
46
+ return {
47
+ rows,
48
+ truncated: false
49
+ };
50
+ } catch (error) {
51
+ await sink.abort();
52
+ throw error;
53
+ } finally {
54
+ port.close();
55
+ }
56
+ };
57
+ const finish = async (port, sink) => {
58
+ await sink.write(await port.send({ kind: "finish" }));
59
+ await sink.close();
60
+ };
61
+ //#endregion
62
+ export { runExport };
@@ -0,0 +1,91 @@
1
+ //#region src/app/export/sink.ts
2
+ const types = {
3
+ xlsx: {
4
+ mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
5
+ extension: ".xlsx",
6
+ description: "Excel workbook"
7
+ },
8
+ csv: {
9
+ mime: "text/csv",
10
+ extension: ".csv",
11
+ description: "CSV file"
12
+ }
13
+ };
14
+ const unsafeName = /[\\/:*?"<>|\u0000-\u001f]/g;
15
+ /** The file's name: the resource's label and the day, such as `Orders 2026-10-16.xlsx`. */
16
+ const exportFileName = (label, format, now = /* @__PURE__ */ new Date()) => {
17
+ const day = [
18
+ now.getFullYear(),
19
+ String(now.getMonth() + 1).padStart(2, "0"),
20
+ String(now.getDate()).padStart(2, "0")
21
+ ].join("-");
22
+ return `${label.replace(unsafeName, " ").trim()} ${day}${types[format].extension}`;
23
+ };
24
+ const blobFlushBytes = 8e6;
25
+ /** Collects the file in a Blob and downloads it under `name` once it is complete, for browsers without a save picker. */
26
+ const createDownloadSink = (name, format) => {
27
+ let blob = new Blob([], { type: types[format].mime });
28
+ let pending = [];
29
+ let pendingBytes = 0;
30
+ const flush = () => {
31
+ blob = new Blob([blob, ...pending], { type: types[format].mime });
32
+ pending = [];
33
+ pendingBytes = 0;
34
+ };
35
+ return {
36
+ write: async (bytes) => {
37
+ if (bytes.length === 0) return;
38
+ pending.push(bytes);
39
+ pendingBytes += bytes.length;
40
+ if (pendingBytes >= blobFlushBytes) flush();
41
+ },
42
+ close: async () => {
43
+ flush();
44
+ const url = URL.createObjectURL(blob);
45
+ const link = document.createElement("a");
46
+ link.href = url;
47
+ link.download = name;
48
+ link.click();
49
+ setTimeout(() => URL.revokeObjectURL(url), 6e4);
50
+ },
51
+ abort: async () => {
52
+ pending = [];
53
+ blob = new Blob([]);
54
+ }
55
+ };
56
+ };
57
+ const savePicker = () => globalThis.showSaveFilePicker;
58
+ /** Whether the browser can write the file to disk as it is built (the File System Access API). */
59
+ const canSaveAsBuilt = () => typeof savePicker() === "function";
60
+ /**
61
+ * Asks where to save the file and writes it there as it is built, so the page holds none of it; `undefined` when the
62
+ * user closes the picker. Call it from the click that starts the export, as the browser asks.
63
+ */
64
+ const pickFileSink = async (name, format) => {
65
+ const pick = savePicker();
66
+ if (!pick) throw new Error("This browser cannot save a file as it is built");
67
+ const type = types[format];
68
+ let handle;
69
+ try {
70
+ handle = await pick({
71
+ suggestedName: name,
72
+ types: [{
73
+ description: type.description,
74
+ accept: { [type.mime]: [type.extension] }
75
+ }]
76
+ });
77
+ } catch (error) {
78
+ if (error instanceof DOMException && error.name === "AbortError") return void 0;
79
+ throw error;
80
+ }
81
+ const writable = await handle.createWritable();
82
+ return {
83
+ write: async (bytes) => {
84
+ if (bytes.length > 0) await writable.write(bytes);
85
+ },
86
+ close: () => writable.close(),
87
+ abort: () => writable.abort()
88
+ };
89
+ };
90
+ //#endregion
91
+ export { canSaveAsBuilt, createDownloadSink, exportFileName, pickFileSink };
@@ -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 };