@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.
- package/LICENSE +202 -0
- package/README.md +65 -2
- package/package.json +54 -3
- package/skills/newui-data/SKILL.md +170 -0
- package/src/aggregate.test.ts +231 -0
- package/src/aggregate.ts +320 -0
- package/src/config-store.test.ts +157 -0
- package/src/config-store.ts +295 -0
- package/src/csv-export.test.ts +156 -0
- package/src/csv-export.ts +129 -0
- package/src/dashboard.test.tsx +285 -0
- package/src/dashboard.tsx +381 -0
- package/src/download.test.ts +14 -0
- package/src/download.ts +39 -0
- package/src/exports.test.ts +135 -0
- package/src/form/autosave.test.ts +186 -0
- package/src/form/autosave.ts +194 -0
- package/src/form/config.test.tsx +288 -0
- package/src/form/config.ts +187 -0
- package/src/form/form.test.tsx +102 -0
- package/src/form/form.tsx +205 -0
- package/src/form/index.ts +3 -0
- package/src/format.test.ts +22 -0
- package/src/format.ts +36 -0
- package/src/index.ts +10 -0
- package/src/query/client.tsx +53 -0
- package/src/query/collection.test.tsx +278 -0
- package/src/query/collection.ts +207 -0
- package/src/query/index.ts +4 -0
- package/src/query/repository.ts +30 -0
- package/src/query/server-table.test.ts +136 -0
- package/src/query/server-table.ts +181 -0
- package/src/resource/client.test.ts +173 -0
- package/src/resource/client.ts +307 -0
- package/src/resource/connect-realtime.test.ts +135 -0
- package/src/resource/connect-realtime.ts +121 -0
- package/src/resource/document.test.ts +151 -0
- package/src/resource/document.ts +198 -0
- package/src/resource/idb.test.ts +169 -0
- package/src/resource/idb.ts +167 -0
- package/src/resource/index.ts +77 -0
- package/src/resource/list.test.ts +150 -0
- package/src/resource/list.ts +230 -0
- package/src/resource/local.test.ts +111 -0
- package/src/resource/local.ts +155 -0
- package/src/resource/realtime.test.ts +331 -0
- package/src/resource/realtime.ts +287 -0
- package/src/resource/resource.test.ts +186 -0
- package/src/resource/resource.ts +213 -0
- package/src/resource/useCall.test.ts +60 -0
- package/src/resource/useCall.ts +74 -0
- package/src/resource/useDoc.test.ts +31 -0
- package/src/resource/useDoc.ts +23 -0
- package/src/resource/useDoctype.test.ts +104 -0
- package/src/resource/useDoctype.ts +128 -0
- package/src/resource/useList.test.ts +32 -0
- package/src/resource/useList.ts +22 -0
- package/src/resource/useNewDoc.test.ts +67 -0
- package/src/resource/useNewDoc.ts +78 -0
- package/src/select.test.ts +13 -0
- package/src/select.ts +19 -0
- package/src/table-persist.test.ts +258 -0
- package/src/table-persist.ts +371 -0
- package/src/undo.test.ts +24 -0
- package/src/undo.ts +37 -0
- package/src/url-serialize.ts +93 -0
- package/src/url-state.test.ts +183 -0
- 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
|
+
}
|