@talelk/newui-data 0.0.0-stage → 1.1.0

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 (68) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +65 -2
  3. package/package.json +54 -3
  4. package/skills/newui-data/SKILL.md +170 -0
  5. package/src/aggregate.test.ts +231 -0
  6. package/src/aggregate.ts +320 -0
  7. package/src/config-store.test.ts +157 -0
  8. package/src/config-store.ts +295 -0
  9. package/src/csv-export.test.ts +156 -0
  10. package/src/csv-export.ts +129 -0
  11. package/src/dashboard.test.tsx +285 -0
  12. package/src/dashboard.tsx +381 -0
  13. package/src/download.test.ts +14 -0
  14. package/src/download.ts +39 -0
  15. package/src/exports.test.ts +135 -0
  16. package/src/form/autosave.test.ts +186 -0
  17. package/src/form/autosave.ts +194 -0
  18. package/src/form/config.test.tsx +288 -0
  19. package/src/form/config.ts +187 -0
  20. package/src/form/form.test.tsx +102 -0
  21. package/src/form/form.tsx +205 -0
  22. package/src/form/index.ts +3 -0
  23. package/src/format.test.ts +22 -0
  24. package/src/format.ts +36 -0
  25. package/src/index.ts +10 -0
  26. package/src/query/client.tsx +53 -0
  27. package/src/query/collection.test.tsx +278 -0
  28. package/src/query/collection.ts +207 -0
  29. package/src/query/index.ts +4 -0
  30. package/src/query/repository.ts +30 -0
  31. package/src/query/server-table.test.ts +136 -0
  32. package/src/query/server-table.ts +181 -0
  33. package/src/resource/client.test.ts +173 -0
  34. package/src/resource/client.ts +307 -0
  35. package/src/resource/connect-realtime.test.ts +135 -0
  36. package/src/resource/connect-realtime.ts +121 -0
  37. package/src/resource/document.test.ts +151 -0
  38. package/src/resource/document.ts +198 -0
  39. package/src/resource/idb.test.ts +169 -0
  40. package/src/resource/idb.ts +167 -0
  41. package/src/resource/index.ts +77 -0
  42. package/src/resource/list.test.ts +150 -0
  43. package/src/resource/list.ts +230 -0
  44. package/src/resource/local.test.ts +111 -0
  45. package/src/resource/local.ts +155 -0
  46. package/src/resource/realtime.test.ts +331 -0
  47. package/src/resource/realtime.ts +287 -0
  48. package/src/resource/resource.test.ts +186 -0
  49. package/src/resource/resource.ts +213 -0
  50. package/src/resource/useCall.test.ts +60 -0
  51. package/src/resource/useCall.ts +74 -0
  52. package/src/resource/useDoc.test.ts +31 -0
  53. package/src/resource/useDoc.ts +23 -0
  54. package/src/resource/useDoctype.test.ts +104 -0
  55. package/src/resource/useDoctype.ts +128 -0
  56. package/src/resource/useList.test.ts +32 -0
  57. package/src/resource/useList.ts +22 -0
  58. package/src/resource/useNewDoc.test.ts +67 -0
  59. package/src/resource/useNewDoc.ts +78 -0
  60. package/src/select.test.ts +13 -0
  61. package/src/select.ts +19 -0
  62. package/src/table-persist.test.ts +258 -0
  63. package/src/table-persist.ts +371 -0
  64. package/src/undo.test.ts +24 -0
  65. package/src/undo.ts +37 -0
  66. package/src/url-serialize.ts +93 -0
  67. package/src/url-state.test.ts +183 -0
  68. package/src/url-state.ts +168 -0
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Versioned documents for user-authored views.
3
+ *
4
+ * `createConfigStore` is one storage abstraction with two consumers: saved
5
+ * filters (the "saved view" side) and dashboard config (the widget-layout
6
+ * side). Both are documents in the same store, distinguished by a `type`
7
+ * discriminator in the envelope — never by separate stores.
8
+ *
9
+ * Documents are versioned envelopes so a future shape migrates per-store, not
10
+ * globally, and the envelope carries `updatedAt` so conflict resolution stays
11
+ * a later decision rather than a later accident. Storage is a seam: the
12
+ * default is `localStorage`, and a server-backed adapter (Frappe `/resource`
13
+ * or any REST) drops in without touching consumers.
14
+ */
15
+
16
+ /** The current envelope shape. A breaking change bumps this and migrates. */
17
+ export const CONFIG_SCHEMA_VERSION = 1
18
+
19
+ /** The byte cap on one stored document — a widget grid that grows forever. */
20
+ export const CONFIG_DOCUMENT_LIMIT = 64 * 1024
21
+
22
+ /** One stored document, with its version and metadata around the payload. */
23
+ export interface ConfigDocument<T = unknown> {
24
+ /** Envelope schema version; see {@link CONFIG_SCHEMA_VERSION}. */
25
+ v: number
26
+ name: string
27
+ /** What kind of document this is (`'saved-view'`, `'dashboard-config'`, …). */
28
+ type?: string
29
+ /** ISO timestamp of the last {@link ConfigStore.save}. */
30
+ updatedAt: string
31
+ /** The document's payload, owned by the consumer. */
32
+ doc: T
33
+ }
34
+
35
+ /** The row {@link ConfigStore.list} returns per stored document. */
36
+ export interface ConfigSummary {
37
+ name: string
38
+ type?: string
39
+ updatedAt: string
40
+ }
41
+
42
+ /**
43
+ * The storage seam. Keys are globally unique; the store namespaces them with
44
+ * its `key` prefix, so `keys()` must enumerate every key the store may read.
45
+ */
46
+ export interface ConfigStorage {
47
+ getItem(key: string): string | null
48
+ setItem(key: string, value: string): void
49
+ removeItem(key: string): void
50
+ /** Every key the storage holds, for the store's namespace scan. */
51
+ keys(): string[]
52
+ }
53
+
54
+ /** Options for {@link createConfigStore}. */
55
+ export interface ConfigStoreOptions {
56
+ /** Namespace prefix, so several stores share one storage. */
57
+ key: string
58
+ /** Backend. Defaults to `localStorage` (in-memory under SSR). */
59
+ storage?: ConfigStorage
60
+ /**
61
+ * Upgrade a document written against an older envelope version. Return the
62
+ * document in the current shape; return `undefined` to reject it. Without a
63
+ * `migrate`, a version mismatch is unreadable rather than silently wrong.
64
+ */
65
+ migrate?: (doc: unknown, from: number) => unknown
66
+ }
67
+
68
+ /** The document API {@link createConfigStore} returns. */
69
+ export interface ConfigStore {
70
+ /** Every stored document, newest first. Unreadable entries are skipped. */
71
+ list(): ConfigSummary[]
72
+ /** Load one document through `migrate`; `undefined` when absent or stale. */
73
+ load<T>(name: string): ConfigDocument<T> | undefined
74
+ /** Store a document in a fresh envelope. False when over the 64KB cap. */
75
+ save<T>(name: string, doc: T, options?: { type?: string }): boolean
76
+ /** Delete one document. Returns whether it existed. */
77
+ remove(name: string): boolean
78
+ /** Move a document to a new name. False when the source is absent. */
79
+ rename(from: string, to: string): boolean
80
+ }
81
+
82
+ function defaultStorage(): ConfigStorage {
83
+ const memory = new Map<string, string>()
84
+ let backend: Storage | null = null
85
+ try {
86
+ backend = typeof localStorage === 'undefined' ? null : localStorage
87
+ } catch {
88
+ // A sandboxed or disabled `localStorage` throws on access, not on use.
89
+ backend = null
90
+ }
91
+ if (!backend) {
92
+ return {
93
+ getItem: (key) => memory.get(key) ?? null,
94
+ setItem: (key, value) => void memory.set(key, value),
95
+ removeItem: (key) => void memory.delete(key),
96
+ keys: () => [...memory.keys()],
97
+ }
98
+ }
99
+ return {
100
+ getItem(key) {
101
+ try {
102
+ return backend.getItem(key)
103
+ } catch {
104
+ return null
105
+ }
106
+ },
107
+ setItem(key, value) {
108
+ // Failures surface to the caller's `save`, which warns and no-ops.
109
+ backend.setItem(key, value)
110
+ },
111
+ removeItem(key) {
112
+ try {
113
+ backend.removeItem(key)
114
+ } catch {
115
+ // The entry is already unreachable.
116
+ }
117
+ },
118
+ keys() {
119
+ const all: string[] = []
120
+ try {
121
+ for (let index = 0; index < backend.length; index++) {
122
+ const key = backend.key(index)
123
+ if (key !== null) all.push(key)
124
+ }
125
+ } catch {
126
+ return all
127
+ }
128
+ return all
129
+ },
130
+ }
131
+ }
132
+
133
+ /**
134
+ * True outside a production build. Bundlers replace `process.env.NODE_ENV`,
135
+ * and this package is type-checked against Node's types, not `vite/client`,
136
+ * so the read goes through `globalThis` with a guard for non-Node hosts.
137
+ */
138
+ function isProduction(): boolean {
139
+ return (
140
+ (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env
141
+ ?.NODE_ENV === 'production'
142
+ )
143
+ }
144
+
145
+ function warnStorageFailure(): void {
146
+ if (!isProduction()) {
147
+ console.warn(
148
+ '[newui-data] createConfigStore: the storage backend rejected a write (quota or disabled storage). The document is dropped, not fatal.',
149
+ )
150
+ }
151
+ }
152
+
153
+ function warnOverLimit(name: string, bytes: number): void {
154
+ if (!isProduction()) {
155
+ console.warn(
156
+ `[newui-data] createConfigStore: document "${name}" is ${bytes} bytes, over the ${CONFIG_DOCUMENT_LIMIT}-byte cap. It was not saved — check what grew (a widget grid that appends forever is the usual suspect).`,
157
+ )
158
+ }
159
+ }
160
+
161
+ function byteLength(text: string): number {
162
+ return new TextEncoder().encode(text).length
163
+ }
164
+
165
+ function isEnvelope(value: unknown): value is ConfigDocument {
166
+ if (value === null || typeof value !== 'object') return false
167
+ const candidate = value as Partial<ConfigDocument>
168
+ return (
169
+ typeof candidate.v === 'number' &&
170
+ typeof candidate.name === 'string' &&
171
+ typeof candidate.updatedAt === 'string' &&
172
+ 'doc' in candidate
173
+ )
174
+ }
175
+
176
+ /**
177
+ * Create a store of named, versioned documents.
178
+ *
179
+ * `save` wraps the payload in a {@link ConfigDocument} envelope; `load` reads
180
+ * it back and runs `migrate` when the stored version is older than the
181
+ * current one. Documents are capped at 64KB with a loud dev warning, so an
182
+ * unbounded document is caught in development instead of in a quota error.
183
+ */
184
+ export function createConfigStore(options: ConfigStoreOptions): ConfigStore {
185
+ const storage = options.storage ?? defaultStorage()
186
+ const namespace = `${options.key}:`
187
+ const scoped = (name: string) => `${namespace}${name}`
188
+ let warned = false
189
+ const warn = (message: string) => {
190
+ if (warned) return
191
+ warned = true
192
+ console.warn(message)
193
+ }
194
+
195
+ /** A backend that throws drops the write and says so, once. */
196
+ const write = (name: string, raw: string): boolean => {
197
+ try {
198
+ storage.setItem(scoped(name), raw)
199
+ return true
200
+ } catch {
201
+ warnStorageFailure()
202
+ return false
203
+ }
204
+ }
205
+
206
+ const read = <T>(name: string): ConfigDocument<T> | undefined => {
207
+ const raw = storage.getItem(scoped(name))
208
+ if (raw === null) return undefined
209
+ let value: unknown
210
+ try {
211
+ value = JSON.parse(raw)
212
+ } catch {
213
+ return undefined
214
+ }
215
+ if (!isEnvelope(value)) return undefined
216
+ const envelope = value as ConfigDocument<T>
217
+ if (envelope.v === CONFIG_SCHEMA_VERSION) return envelope
218
+ if (!options.migrate) {
219
+ warn(
220
+ `[newui-data] createConfigStore: "${name}" is v${envelope.v}, the store reads v${CONFIG_SCHEMA_VERSION}, and no migrate was supplied. It is unreadable, not corrupted.`,
221
+ )
222
+ return undefined
223
+ }
224
+ const migrated = options.migrate(envelope.doc, envelope.v)
225
+ if (migrated === undefined) return undefined
226
+ return { ...envelope, v: CONFIG_SCHEMA_VERSION, doc: migrated as T }
227
+ }
228
+
229
+ return {
230
+ list() {
231
+ const summaries: ConfigSummary[] = []
232
+ for (const key of storage.keys()) {
233
+ if (!key.startsWith(namespace)) continue
234
+ const name = key.slice(namespace.length)
235
+ const envelope = read(name)
236
+ if (!envelope) continue
237
+ const summary: ConfigSummary = {
238
+ name,
239
+ updatedAt: envelope.updatedAt,
240
+ }
241
+ if (envelope.type !== undefined) summary.type = envelope.type
242
+ summaries.push(summary)
243
+ }
244
+ return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
245
+ },
246
+ load<T>(name: string) {
247
+ return read<T>(name)
248
+ },
249
+ save<T>(name: string, doc: T, saveOptions: { type?: string } = {}) {
250
+ const envelope: ConfigDocument<T> = {
251
+ v: CONFIG_SCHEMA_VERSION,
252
+ name,
253
+ ...(saveOptions.type !== undefined ? { type: saveOptions.type } : {}),
254
+ updatedAt: new Date().toISOString(),
255
+ doc,
256
+ }
257
+ const raw = JSON.stringify(envelope)
258
+ const bytes = byteLength(raw)
259
+ if (bytes > CONFIG_DOCUMENT_LIMIT) {
260
+ warnOverLimit(name, bytes)
261
+ return false
262
+ }
263
+ return write(name, raw)
264
+ },
265
+ remove(name: string) {
266
+ const existed = storage.getItem(scoped(name)) !== null
267
+ try {
268
+ storage.removeItem(scoped(name))
269
+ } catch {
270
+ // The entry is unreachable to the store either way.
271
+ }
272
+ return existed
273
+ },
274
+ rename(from: string, to: string) {
275
+ const envelope = read(from)
276
+ if (!envelope) return false
277
+ const raw = JSON.stringify({
278
+ ...envelope,
279
+ name: to,
280
+ updatedAt: new Date().toISOString(),
281
+ } satisfies ConfigDocument)
282
+ if (byteLength(raw) > CONFIG_DOCUMENT_LIMIT) {
283
+ warnOverLimit(to, byteLength(raw))
284
+ return false
285
+ }
286
+ if (!write(to, raw)) return false
287
+ try {
288
+ storage.removeItem(scoped(from))
289
+ } catch {
290
+ // The renamed copy is saved; the source is already gone for us.
291
+ }
292
+ return true
293
+ },
294
+ }
295
+ }
@@ -0,0 +1,156 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ type CsvExportTable,
4
+ type CsvExportTableRow,
5
+ exportCsv,
6
+ } from './csv-export'
7
+
8
+ interface FakeColumn {
9
+ id: string
10
+ visible?: boolean
11
+ meta?: Record<string, unknown>
12
+ }
13
+
14
+ function row(values: Record<string, unknown>): CsvExportTableRow {
15
+ return { getValue: (columnId) => values[columnId] }
16
+ }
17
+
18
+ function fakeTable(options: {
19
+ columns: FakeColumn[]
20
+ /** The raw data — what the core model returns. */
21
+ rows: Record<string, unknown>[]
22
+ /** The filtered, sorted view — what the filtered model returns. */
23
+ filtered?: Record<string, unknown>[]
24
+ pageIndex?: number
25
+ pageSize?: number
26
+ }): CsvExportTable {
27
+ const leaf = (visibleOnly: boolean) =>
28
+ options.columns
29
+ .filter((column) => !visibleOnly || column.visible !== false)
30
+ .map((column) => ({
31
+ id: column.id,
32
+ columnDef: column.meta ? { meta: column.meta } : {},
33
+ }))
34
+ return {
35
+ getAllLeafColumns: () => leaf(false),
36
+ getVisibleLeafColumns: () => leaf(true),
37
+ getCoreRowModel: () => ({ rows: options.rows.map(row) }),
38
+ getFilteredRowModel: () => ({
39
+ rows: (options.filtered ?? options.rows).map(row),
40
+ }),
41
+ getState: () => ({
42
+ pagination: {
43
+ pageIndex: options.pageIndex ?? 0,
44
+ pageSize: options.pageSize ?? 10,
45
+ },
46
+ }),
47
+ }
48
+ }
49
+
50
+ async function csv(blob: Blob): Promise<string> {
51
+ return blob.text()
52
+ }
53
+
54
+ const five = [
55
+ { id: 1, name: 'a' },
56
+ { id: 2, name: 'b' },
57
+ { id: 3, name: 'c' },
58
+ { id: 4, name: 'd' },
59
+ { id: 5, name: 'e' },
60
+ ]
61
+
62
+ describe('exportCsv', () => {
63
+ it('exports the filtered view with visible columns by default', async () => {
64
+ const table = fakeTable({
65
+ columns: [{ id: 'id' }, { id: 'name' }, { id: 'secret', visible: false }],
66
+ rows: five,
67
+ filtered: five.slice(0, 2),
68
+ })
69
+ const blob = await exportCsv(table)
70
+ expect(blob.type).toBe('text/csv;charset=utf-8')
71
+ expect(await csv(blob)).toBe('"id","name"\r\n"1","a"\r\n"2","b"')
72
+ })
73
+
74
+ it('scope page takes the current page slice', async () => {
75
+ const table = fakeTable({
76
+ columns: [{ id: 'id' }, { id: 'name' }],
77
+ rows: five,
78
+ pageIndex: 1,
79
+ pageSize: 2,
80
+ })
81
+ expect(await csv(await exportCsv(table, { scope: 'page' }))).toBe(
82
+ '"id","name"\r\n"3","c"\r\n"4","d"',
83
+ )
84
+ })
85
+
86
+ it('scope all ignores the filtered model', async () => {
87
+ const table = fakeTable({
88
+ columns: [{ id: 'id' }],
89
+ rows: five,
90
+ filtered: five.slice(0, 1),
91
+ })
92
+ const text = await csv(await exportCsv(table, { scope: 'all' }))
93
+ expect(text.split('\r\n')).toHaveLength(6)
94
+ expect(text).toContain('"5"')
95
+ })
96
+
97
+ it('columns all ships hidden columns too', async () => {
98
+ const table = fakeTable({
99
+ columns: [{ id: 'id' }, { id: 'secret', visible: false }],
100
+ rows: [{ id: 1, secret: 'x' }],
101
+ })
102
+ expect(await csv(await exportCsv(table, { columns: 'all' }))).toBe(
103
+ '"id","secret"\r\n"1","x"',
104
+ )
105
+ })
106
+
107
+ it('honours the export meta keys: name, format, exportable', async () => {
108
+ const table = fakeTable({
109
+ columns: [
110
+ { id: 'name', meta: { exportName: 'Full name' } },
111
+ {
112
+ id: 'amount',
113
+ meta: { format: (value: unknown) => `$${Number(value).toFixed(2)}` },
114
+ },
115
+ { id: 'internal', meta: { exportable: false } },
116
+ ],
117
+ rows: [{ name: 'ada', amount: 12.5, internal: 'keep out' }],
118
+ })
119
+ expect(await csv(await exportCsv(table))).toBe(
120
+ '"Full name","amount"\r\n"ada","$12.50"',
121
+ )
122
+ })
123
+
124
+ it('renders null and undefined as empty cells', async () => {
125
+ const table = fakeTable({
126
+ columns: [{ id: 'a' }, { id: 'b' }],
127
+ rows: [{ a: null, b: undefined }],
128
+ })
129
+ expect(await csv(await exportCsv(table))).toBe('"a","b"\r\n"",""')
130
+ })
131
+
132
+ it('quotes and escapes through csvText, including inner quotes', async () => {
133
+ const table = fakeTable({
134
+ columns: [{ id: 'quote' }],
135
+ rows: [{ quote: 'said "hi", loudly' }],
136
+ })
137
+ expect(await csv(await exportCsv(table))).toBe(
138
+ '"quote"\r\n"said ""hi"", loudly"',
139
+ )
140
+ })
141
+
142
+ it('falls back to String for values that are neither text nor numbers', async () => {
143
+ const table = fakeTable({
144
+ columns: [{ id: 'flag' }],
145
+ rows: [{ flag: true }],
146
+ })
147
+ expect(await csv(await exportCsv(table))).toBe('"flag"\r\n"true"')
148
+ })
149
+
150
+ it('is a promise — the call site never blocks', async () => {
151
+ const table = fakeTable({ columns: [{ id: 'id' }], rows: five })
152
+ const pending = exportCsv(table)
153
+ expect(pending).toBeInstanceOf(Promise)
154
+ await pending
155
+ })
156
+ })
@@ -0,0 +1,129 @@
1
+ import { csvText } from './download'
2
+
3
+ /**
4
+ * Table → CSV, exporting what the user sees.
5
+ *
6
+ * `exportCsv` is the headless binding between a table's state and a download:
7
+ * it reads the row models and the column state through a structural interface
8
+ * (the shape TanStack Table already satisfies), renders the cells through the
9
+ * column's own `meta.format` when it has one, and hands back a `Blob` built
10
+ * on `csvText`. A page finishes the job with `downloadBlob`.
11
+ *
12
+ * It returns a promise so a server-side or streaming export can replace the
13
+ * in-browser one without the call site noticing.
14
+ */
15
+
16
+ /** Which rows to write. */
17
+ export type CsvScope =
18
+ /** The current page slice of the filtered, sorted view. */
19
+ | 'page'
20
+ /** Every filtered row in the current sort order, across pages. */
21
+ | 'filtered'
22
+ /** Every row, ignoring filters — the raw data. */
23
+ | 'all'
24
+
25
+ /** Which columns to write. */
26
+ export type CsvColumns =
27
+ /** The columns the user can see. */
28
+ | 'visible'
29
+ /** Every leaf column, hidden ones included. */
30
+ | 'all'
31
+
32
+ /** Options for {@link exportCsv}. */
33
+ export interface CsvExportOptions {
34
+ /** Default `'filtered'` — what a reader of the file would expect. */
35
+ scope?: CsvScope
36
+ /** Default `'visible'`. A column with `meta.exportable: false` never ships. */
37
+ columns?: CsvColumns
38
+ }
39
+
40
+ /**
41
+ * The `columnDef.meta` keys `exportCsv` reads, all optional:
42
+ * `exportName` (the header text), `exportable` (`false` excludes the column)
43
+ * and `format` (the cell formatter the export shares with the rendered cell).
44
+ */
45
+ export interface CsvExportTableColumn {
46
+ id: string
47
+ columnDef?: { meta?: Record<string, unknown> }
48
+ }
49
+
50
+ /** A row: one value per column id, like TanStack's `row.getValue`. */
51
+ export interface CsvExportTableRow {
52
+ getValue(columnId: string): unknown
53
+ }
54
+
55
+ /** A row model — the `.rows` of TanStack's row models. */
56
+ export interface CsvExportRowModel {
57
+ rows: readonly CsvExportTableRow[]
58
+ }
59
+
60
+ /**
61
+ * The slice of a table instance `exportCsv` reads. A TanStack `Table`
62
+ * satisfies it structurally; a test can hand it a literal.
63
+ */
64
+ export interface CsvExportTable {
65
+ getAllLeafColumns(): readonly CsvExportTableColumn[]
66
+ getVisibleLeafColumns(): readonly CsvExportTableColumn[]
67
+ getCoreRowModel(): CsvExportRowModel
68
+ getFilteredRowModel(): CsvExportRowModel
69
+ getState(): { pagination: { pageIndex: number; pageSize: number } }
70
+ }
71
+
72
+ function cellText(value: unknown, format?: unknown): string | number {
73
+ if (format !== undefined && typeof format === 'function') {
74
+ return (format as (value: unknown) => string | number)(value)
75
+ }
76
+ if (value === null || value === undefined) return ''
77
+ if (typeof value === 'number' || typeof value === 'string') return value
78
+ return String(value)
79
+ }
80
+
81
+ /**
82
+ * Export a table's state as CSV. The scope chooses the rows — the page, the
83
+ * filtered set, or everything — and the columns default to what is visible.
84
+ * Resolve with the `Blob`, then `downloadBlob(name, blob)` to save it.
85
+ */
86
+ export async function exportCsv(
87
+ table: CsvExportTable,
88
+ options: CsvExportOptions = {},
89
+ ): Promise<Blob> {
90
+ const scope = options.scope ?? 'filtered'
91
+ const columns = options.columns ?? 'visible'
92
+
93
+ const leaf =
94
+ columns === 'visible'
95
+ ? table.getVisibleLeafColumns()
96
+ : table.getAllLeafColumns()
97
+ const exported = leaf.filter(
98
+ (column) => column.columnDef?.meta?.['exportable'] !== false,
99
+ )
100
+
101
+ let rows: readonly CsvExportTableRow[]
102
+ if (scope === 'all') {
103
+ rows = table.getCoreRowModel().rows
104
+ } else {
105
+ const filtered = table.getFilteredRowModel().rows
106
+ if (scope === 'page') {
107
+ const { pageIndex, pageSize } = table.getState().pagination
108
+ const start = pageIndex * pageSize
109
+ rows = filtered.slice(start, start + pageSize)
110
+ } else {
111
+ rows = filtered
112
+ }
113
+ }
114
+
115
+ const header = exported.map((column) =>
116
+ typeof column.columnDef?.meta?.['exportName'] === 'string'
117
+ ? (column.columnDef.meta['exportName'] as string)
118
+ : column.id,
119
+ )
120
+ const body = rows.map((row) =>
121
+ exported.map((column) =>
122
+ cellText(row.getValue(column.id), column.columnDef?.meta?.['format']),
123
+ ),
124
+ )
125
+
126
+ return new Blob([csvText([header, ...body])], {
127
+ type: 'text/csv;charset=utf-8',
128
+ })
129
+ }