@talelk/newui-data 1.0.0 → 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.
@@ -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
+ }
@@ -0,0 +1,285 @@
1
+ import { render, screen } from '@solidjs/testing-library'
2
+ import { createRoot, type JSX } from 'solid-js'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import {
5
+ createDashboardState,
6
+ DashboardProvider,
7
+ type DashboardState,
8
+ type DashboardStateOptions,
9
+ useDashboardState,
10
+ } from './dashboard'
11
+ import type { UrlStateAdapter } from './url-state'
12
+
13
+ /** A fixed clock: Sunday 15 March 2026, 14:30 local — Q1, a leap year. */
14
+ const NOW = new Date(2026, 2, 15, 14, 30)
15
+
16
+ function setup(options: DashboardStateOptions = {}) {
17
+ let dispose!: () => void
18
+ let state!: DashboardState
19
+ createRoot((d) => {
20
+ dispose = d
21
+ state = createDashboardState({ now: () => NOW, ...options })
22
+ })
23
+ return { state, dispose }
24
+ }
25
+
26
+ function adapter(
27
+ search = '',
28
+ ): UrlStateAdapter & { last: () => URLSearchParams } {
29
+ let last = new URLSearchParams(search)
30
+ return {
31
+ search: () => search,
32
+ replace: vi.fn((params: URLSearchParams) => {
33
+ last = params
34
+ }),
35
+ last: () => last,
36
+ }
37
+ }
38
+
39
+ /** Let queued microtasks (the per-tick `replace`) run. */
40
+ async function flushMicrotasks(): Promise<void> {
41
+ await Promise.resolve()
42
+ await Promise.resolve()
43
+ }
44
+
45
+ const endOfToday = new Date(2026, 2, 15, 23, 59, 59, 999)
46
+
47
+ describe('createDashboardState — time range', () => {
48
+ it('defaults to the current 7 days, ending at today', () => {
49
+ const { state, dispose } = setup()
50
+ const range = state.timeRange()
51
+ expect(range.preset).toBe('7d')
52
+ expect(range.from).toEqual(new Date(2026, 2, 9))
53
+ expect(range.to).toEqual(endOfToday)
54
+ dispose()
55
+ })
56
+
57
+ it('computes 30d, qtd and ytd from the same clock', () => {
58
+ const { state, dispose } = setup()
59
+
60
+ state.setTimeRange('30d')
61
+ expect(state.timeRange()).toEqual({
62
+ preset: '30d',
63
+ from: new Date(2026, 1, 14),
64
+ to: endOfToday,
65
+ })
66
+
67
+ state.setTimeRange('qtd')
68
+ expect(state.timeRange()).toEqual({
69
+ preset: 'qtd',
70
+ from: new Date(2026, 0, 1),
71
+ to: endOfToday,
72
+ })
73
+
74
+ state.setTimeRange('ytd')
75
+ expect(state.timeRange()).toEqual({
76
+ preset: 'ytd',
77
+ from: new Date(2026, 0, 1),
78
+ to: endOfToday,
79
+ })
80
+ dispose()
81
+ })
82
+
83
+ it('floors qtd to the quarter, not the year', () => {
84
+ let dispose!: () => void
85
+ let state!: DashboardState
86
+ createRoot((d) => {
87
+ dispose = d
88
+ state = createDashboardState({
89
+ now: () => new Date(2026, 6, 11, 9, 0),
90
+ })
91
+ })
92
+ state.setTimeRange('qtd')
93
+ expect(state.timeRange().from).toEqual(new Date(2026, 6, 1))
94
+ dispose()
95
+ })
96
+
97
+ it('marks set dates as custom, and a preset as not', () => {
98
+ const { state, dispose } = setup()
99
+ state.setTimeRange({
100
+ from: new Date(2026, 4, 1),
101
+ to: new Date(2026, 4, 8),
102
+ })
103
+ expect(state.timeRange()).toEqual({
104
+ preset: 'custom',
105
+ from: new Date(2026, 4, 1),
106
+ to: new Date(2026, 4, 8),
107
+ })
108
+
109
+ state.setTimeRange('30d')
110
+ expect(state.timeRange().preset).toBe('30d')
111
+ dispose()
112
+ })
113
+
114
+ it('keeps the other bound when a custom range sets only one', () => {
115
+ const { state, dispose } = setup()
116
+ const to = state.timeRange().to
117
+ state.setTimeRange({ from: new Date(2026, 1, 1) })
118
+ expect(state.timeRange()).toEqual({
119
+ preset: 'custom',
120
+ from: new Date(2026, 1, 1),
121
+ to,
122
+ })
123
+ dispose()
124
+ })
125
+
126
+ it('ignores an unknown preset', () => {
127
+ const { state, dispose } = setup()
128
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
129
+ state.setTimeRange('fortnight' as '7d')
130
+ expect(state.timeRange().preset).toBe('7d')
131
+ expect(warn).toHaveBeenCalled()
132
+ warn.mockRestore()
133
+ dispose()
134
+ })
135
+ })
136
+
137
+ describe('createDashboardState — cross filter', () => {
138
+ it('holds one slot: a new value replaces, the same value clears', () => {
139
+ const { state, dispose } = setup()
140
+ expect(state.cross()).toBeNull()
141
+
142
+ state.setCross({ column: 'region', value: 'EMEA' })
143
+ expect(state.cross()).toEqual({ column: 'region', value: 'EMEA' })
144
+
145
+ state.setCross({ column: 'region', value: 'APAC' })
146
+ expect(state.cross()).toEqual({ column: 'region', value: 'APAC' })
147
+
148
+ // The same click twice is a toggle, not a second filter.
149
+ state.setCross({ column: 'region', value: 'APAC' })
150
+ expect(state.cross()).toBeNull()
151
+
152
+ state.setCross({ column: 'stage', value: 2 })
153
+ state.setCross(null)
154
+ expect(state.cross()).toBeNull()
155
+ dispose()
156
+ })
157
+ })
158
+
159
+ describe('createDashboardState — filters', () => {
160
+ it('reads declared filters, keeps defaults, and takes ad hoc keys', () => {
161
+ const { state, dispose } = setup({
162
+ filters: { region: 'EMEA', period: undefined },
163
+ })
164
+ expect(state.filters()).toEqual({ region: 'EMEA', period: undefined })
165
+
166
+ state.setFilter('period', 30)
167
+ expect(state.filters()['period']).toBe(30)
168
+
169
+ // An undeclared key still works and still serializes.
170
+ state.setFilter('source', 'web')
171
+ expect(state.filters()['source']).toBe('web')
172
+ expect(state.params().get('f:source')).toBe('web')
173
+
174
+ // `undefined` returns a declared key to its default, not to nothing.
175
+ state.setFilter('region', undefined)
176
+ expect(state.filters()['region']).toBe('EMEA')
177
+ dispose()
178
+ })
179
+
180
+ it('applies setFilters as a merge', () => {
181
+ const { state, dispose } = setup({ filters: { region: 'EMEA' } })
182
+ state.setFilters({ region: 'APAC', stage: 'won' })
183
+ expect(state.filters()).toEqual({ region: 'APAC', stage: 'won' })
184
+ dispose()
185
+ })
186
+ })
187
+
188
+ describe('createDashboardState — URL sync', () => {
189
+ it('deep-links: the URL seeds the state on creation', () => {
190
+ const { state, dispose } = setup({
191
+ adapter: adapter(
192
+ '?range=30d&f:region=EMEA&cross=%7B%22column%22%3A%22region%22%2C%22value%22%3A%22EMEA%22%7D',
193
+ ),
194
+ filters: { region: '' },
195
+ })
196
+ expect(state.timeRange().preset).toBe('30d')
197
+ expect(state.filters()['region']).toBe('EMEA')
198
+ expect(state.cross()).toEqual({ column: 'region', value: 'EMEA' })
199
+ dispose()
200
+ })
201
+
202
+ it('writes state back and prunes what is only the default', async () => {
203
+ const bridge = adapter()
204
+ const { state, dispose } = setup({
205
+ adapter: bridge,
206
+ filters: { region: '' },
207
+ })
208
+
209
+ state.setTimeRange('30d')
210
+ state.setFilter('region', 'EMEA')
211
+ state.setCross({ column: 'stage', value: 'won' })
212
+ await flushMicrotasks()
213
+
214
+ const params = bridge.last()
215
+ expect(params.get('range')).toBe('30d')
216
+ expect(params.get('f:region')).toBe('EMEA')
217
+ expect(JSON.parse(params.get('cross') ?? '')).toEqual({
218
+ column: 'stage',
219
+ value: 'won',
220
+ })
221
+
222
+ // Back to the default preset: the param goes away rather than say nothing.
223
+ state.setTimeRange('7d')
224
+ await flushMicrotasks()
225
+ expect(bridge.last().get('range')).toBeNull()
226
+ dispose()
227
+ })
228
+
229
+ it('clears the custom dates when a preset returns', async () => {
230
+ const bridge = adapter()
231
+ const { state, dispose } = setup({ adapter: bridge })
232
+
233
+ state.setTimeRange({ from: new Date(2026, 3, 1), to: new Date(2026, 3, 5) })
234
+ await flushMicrotasks()
235
+ expect(bridge.last().get('from')).toBe(new Date(2026, 3, 1).toISOString())
236
+
237
+ state.setTimeRange('qtd')
238
+ await flushMicrotasks()
239
+ expect(bridge.last().get('from')).toBeNull()
240
+ expect(bridge.last().get('to')).toBeNull()
241
+ dispose()
242
+ })
243
+
244
+ it('touches no URL when sync is off, but still serializes params', async () => {
245
+ const bridge = adapter()
246
+ const { state, dispose } = setup({ adapter: bridge, sync: false })
247
+ state.setTimeRange('30d')
248
+ await flushMicrotasks()
249
+ expect(bridge.replace).not.toHaveBeenCalled()
250
+ expect(state.params().get('range')).toBe('30d')
251
+ dispose()
252
+ })
253
+
254
+ it('treats a custom range whose dates no longer parse as a reset', () => {
255
+ const { state, dispose } = setup({
256
+ adapter: adapter('?range=custom&from=not-a-date&to=also-no'),
257
+ })
258
+ expect(state.timeRange().preset).toBe('custom')
259
+ expect(state.timeRange().from).toEqual(new Date(2026, 2, 9))
260
+ dispose()
261
+ })
262
+ })
263
+
264
+ describe('useDashboardState', () => {
265
+ function Widget(): JSX.Element {
266
+ const dashboard = useDashboardState()
267
+ return <p>{dashboard.timeRange().preset}</p>
268
+ }
269
+
270
+ it('reads the state published by the provider', () => {
271
+ const { state, dispose } = setup()
272
+ state.setTimeRange('ytd')
273
+ render(() => (
274
+ <DashboardProvider state={state}>
275
+ <Widget />
276
+ </DashboardProvider>
277
+ ))
278
+ expect(screen.getByText('ytd')).toBeTruthy()
279
+ dispose()
280
+ })
281
+
282
+ it('names the provider when used outside one', () => {
283
+ expect(() => render(() => <Widget />)).toThrow(/DashboardProvider/)
284
+ })
285
+ })