@startsimpli/ui 0.4.103 → 0.4.105
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/package.json +6 -5
- package/src/components/document-editor/DocumentEditor.tsx +11 -2
- package/src/components/navigation/__tests__/breadcrumbs.test.tsx +16 -0
- package/src/components/navigation/breadcrumbs.tsx +39 -0
- package/src/components/navigation/index.ts +1 -0
- package/src/components/unified-table/UnifiedTable.tsx +30 -2
- package/src/components/unified-table/__tests__/integration/search-and-empty-state.test.tsx +210 -0
- package/src/components/unified-table/components/DataTableCore/index.tsx +6 -2
- package/src/components/unified-table/components/Toolbar/StandardTableToolbar.tsx +53 -3
- package/src/components/unified-table/utils/export.ts +5 -37
- package/src/foundry/LinkPanel.tsx +394 -0
- package/src/foundry/__tests__/LinkPanel.test.tsx +139 -0
- package/src/foundry/__tests__/links.test.ts +251 -0
- package/src/foundry/__tests__/list-query.test.ts +200 -0
- package/src/foundry/__tests__/publication.test.ts +227 -0
- package/src/foundry/__tests__/record-validation.test.ts +132 -0
- package/src/foundry/__tests__/types.test.ts +53 -0
- package/src/foundry/index.ts +21 -0
- package/src/foundry/links.ts +227 -0
- package/src/foundry/list-query.ts +241 -0
- package/src/foundry/publication.ts +260 -0
- package/src/foundry/record-validation.ts +109 -0
- package/src/foundry/system-attrs.ts +51 -0
- package/src/foundry/types.ts +137 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/use-now.ts +46 -0
- package/src/index.ts +1 -0
- package/src/utils/__tests__/csv.test.ts +67 -0
- package/src/utils/__tests__/download-csv.test.ts +40 -0
- package/src/utils/csv.ts +77 -0
- package/src/utils/index.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.105",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
".": "./src/index.ts",
|
|
9
9
|
"./catalog": "./src/catalog/index.ts",
|
|
10
10
|
"./table": "./src/components/unified-table/index.ts",
|
|
11
|
+
"./foundry": "./src/foundry/index.ts",
|
|
11
12
|
"./gantt": "./src/components/gantt/index.ts",
|
|
12
13
|
"./gantt/styles": "./src/components/gantt/gantt.css",
|
|
13
14
|
"./components": "./src/components/index.ts",
|
|
@@ -44,9 +45,9 @@
|
|
|
44
45
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
45
46
|
"react": "^18.0.0 || ^19.0.0",
|
|
46
47
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
47
|
-
"@startsimpli/api": "^0.5.56",
|
|
48
48
|
"@startsimpli/auth": "^0.4.35",
|
|
49
|
-
"@startsimpli/i18n": "^0.1.3"
|
|
49
|
+
"@startsimpli/i18n": "^0.1.3",
|
|
50
|
+
"@startsimpli/api": "^0.5.56"
|
|
50
51
|
},
|
|
51
52
|
"peerDependenciesMeta": {
|
|
52
53
|
"@startsimpli/api": {
|
|
@@ -120,8 +121,8 @@
|
|
|
120
121
|
"tailwindcss": "^3.4.19",
|
|
121
122
|
"tsup": "^8.5.1",
|
|
122
123
|
"typescript": "^6.0.3",
|
|
123
|
-
"@startsimpli/
|
|
124
|
-
"@startsimpli/
|
|
124
|
+
"@startsimpli/i18n": "0.1.3",
|
|
125
|
+
"@startsimpli/realtime": "0.0.2"
|
|
125
126
|
},
|
|
126
127
|
"keywords": [
|
|
127
128
|
"ui",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import * as React from 'react';
|
|
14
14
|
import { Columns2, Eye, Pencil, Plus, Trash2, Loader2, Check, AlertCircle } from 'lucide-react';
|
|
15
|
+
import { wordCount } from '../validation/checks';
|
|
15
16
|
|
|
16
17
|
import { cn } from '../../lib/utils';
|
|
17
18
|
import { MarkdownRenderer } from '../blog/MarkdownRenderer';
|
|
@@ -34,9 +35,17 @@ export interface DocumentEditorProps {
|
|
|
34
35
|
className?: string;
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* ONE word counter in this package, not two (bd startsim-wn2p.28).
|
|
40
|
+
*
|
|
41
|
+
* This file had its own `split(/\s+/)` copy, so after the validation rail
|
|
42
|
+
* learned to count Chinese the editor still did not: the same Chinese draft
|
|
43
|
+
* showed "Blog word count 524 words" in the rail and "16 words" on the tab
|
|
44
|
+
* heading directly above it. A reader cannot tell which number to believe, and
|
|
45
|
+
* the honest answer was that only one of them was counting.
|
|
46
|
+
*/
|
|
37
47
|
function countWords(s: string): number {
|
|
38
|
-
|
|
39
|
-
return t ? t.split(/\s+/).length : 0;
|
|
48
|
+
return wordCount(s);
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
function asString(v: unknown): string {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { Breadcrumbs } from '../breadcrumbs'
|
|
3
|
+
|
|
4
|
+
describe('Breadcrumbs', () => {
|
|
5
|
+
it('renders nothing rather than an empty nav', () => {
|
|
6
|
+
const { container } = render(<Breadcrumbs crumbs={[]} />)
|
|
7
|
+
expect(container.firstChild).toBeNull()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('links every crumb but the one you are on', () => {
|
|
11
|
+
render(<Breadcrumbs crumbs={[{ label: 'Home', href: '/' }, { label: 'Products' }]} />)
|
|
12
|
+
expect(screen.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '/')
|
|
13
|
+
expect(screen.queryByRole('link', { name: 'Products' })).not.toBeInTheDocument()
|
|
14
|
+
expect(screen.getByText('Products')).toHaveAttribute('aria-current', 'page')
|
|
15
|
+
})
|
|
16
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
|
|
3
|
+
export interface Crumb {
|
|
4
|
+
label: string;
|
|
5
|
+
/** Omitted on the last crumb — the page you are already on is not a link. */
|
|
6
|
+
href?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Where you are, one line above the heading.
|
|
11
|
+
*
|
|
12
|
+
* DELIBERATELY FLAT. A breadcrumb generated from a menu config goes BLANK on
|
|
13
|
+
* every route the menu has no entry for — a trail that disappears exactly where
|
|
14
|
+
* a trail is most useful. This renders the crumbs it is handed, in order, and
|
|
15
|
+
* has no opinion about where they came from.
|
|
16
|
+
*/
|
|
17
|
+
export function Breadcrumbs({ crumbs }: { crumbs: Crumb[] }) {
|
|
18
|
+
if (crumbs.length === 0) return null;
|
|
19
|
+
return (
|
|
20
|
+
<nav aria-label="Breadcrumb">
|
|
21
|
+
<ol className="flex flex-wrap items-center gap-1.5 text-xs text-gray-500">
|
|
22
|
+
{crumbs.map((crumb, i) => (
|
|
23
|
+
<li key={`${crumb.label}-${i}`} className="flex items-center gap-1.5">
|
|
24
|
+
{i > 0 ? <span aria-hidden>/</span> : null}
|
|
25
|
+
{crumb.href ? (
|
|
26
|
+
<Link href={crumb.href} className="hover:text-gray-900 hover:underline">
|
|
27
|
+
{crumb.label}
|
|
28
|
+
</Link>
|
|
29
|
+
) : (
|
|
30
|
+
<span className="text-gray-700" aria-current="page">
|
|
31
|
+
{crumb.label}
|
|
32
|
+
</span>
|
|
33
|
+
)}
|
|
34
|
+
</li>
|
|
35
|
+
))}
|
|
36
|
+
</ol>
|
|
37
|
+
</nav>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -306,6 +306,12 @@ export function UnifiedTable<TData>({
|
|
|
306
306
|
useEffect(() => {
|
|
307
307
|
if (!searchConfig?.enabled || !searchConfig.preserveFocus) return
|
|
308
308
|
if (!shouldRestoreFocusRef.current) return
|
|
309
|
+
// Already focused means the user never lost it — re-focusing would only move
|
|
310
|
+
// the caret to the end, which with a debounced search happens mid-word.
|
|
311
|
+
if (searchInputRef.current && document.activeElement === searchInputRef.current) {
|
|
312
|
+
shouldRestoreFocusRef.current = false
|
|
313
|
+
return
|
|
314
|
+
}
|
|
309
315
|
if (searchInputRef.current) {
|
|
310
316
|
searchInputRef.current.focus()
|
|
311
317
|
const len = searchConfig.value?.length ?? 0
|
|
@@ -400,8 +406,28 @@ export function UnifiedTable<TData>({
|
|
|
400
406
|
)
|
|
401
407
|
}
|
|
402
408
|
|
|
403
|
-
//
|
|
404
|
-
|
|
409
|
+
// IS THE LIST EMPTY BECAUSE THE USER NARROWED IT?
|
|
410
|
+
//
|
|
411
|
+
// `'all'` is TableFilters' own "no filter" sentinel, so a section sitting on it
|
|
412
|
+
// is not narrowing anything.
|
|
413
|
+
const hasNarrowingFilter = Object.values(filters.filters ?? {}).some((value) => {
|
|
414
|
+
if (value == null || value === '') return false
|
|
415
|
+
if (typeof value === 'string' && value.toLowerCase() === 'all') return false
|
|
416
|
+
if (Array.isArray(value)) return value.length > 0
|
|
417
|
+
return true
|
|
418
|
+
})
|
|
419
|
+
const isNarrowed = (searchConfig?.value ?? '').trim() !== '' || hasNarrowingFilter
|
|
420
|
+
|
|
421
|
+
// Empty state.
|
|
422
|
+
//
|
|
423
|
+
// REPLACING THE WHOLE TABLE IS ONLY RIGHT WHEN THERE IS NOTHING TO UNDO. A list
|
|
424
|
+
// emptied BY a search or a filter used to lose the toolbar as well — including
|
|
425
|
+
// the search box the user needs to widen it again — which turns "search for
|
|
426
|
+
// something that is not there" into a dead end with no way back but a reload.
|
|
427
|
+
// So the full replacement is kept for a genuinely empty list (unchanged for
|
|
428
|
+
// every consumer), and a narrowed empty list keeps its toolbar and filters and
|
|
429
|
+
// renders the same `emptyState` node where the rows would be.
|
|
430
|
+
if (!loading && sortedData.length === 0 && emptyState && !isNarrowed) {
|
|
405
431
|
return <div className={className}>{emptyState}</div>
|
|
406
432
|
}
|
|
407
433
|
|
|
@@ -421,6 +447,7 @@ export function UnifiedTable<TData>({
|
|
|
421
447
|
}}
|
|
422
448
|
searchInputRef={searchInputRef}
|
|
423
449
|
searchAutoFocus={searchConfig?.autoFocus}
|
|
450
|
+
searchDebounceMs={searchConfig?.debounceMs}
|
|
424
451
|
columnVisibilityEnabled={columnVisibilityConfig?.enabled}
|
|
425
452
|
hideableColumns={hideableColumns}
|
|
426
453
|
columnVisibility={columnVisibility}
|
|
@@ -543,6 +570,7 @@ export function UnifiedTable<TData>({
|
|
|
543
570
|
rowActions={rowActions}
|
|
544
571
|
onRowClick={onRowClick}
|
|
545
572
|
loadingRows={loadingRows}
|
|
573
|
+
emptyState={emptyState}
|
|
546
574
|
/>
|
|
547
575
|
|
|
548
576
|
{/* Pagination */}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two defects every foundry fork silently inherited (bd startsim-jugjd).
|
|
3
|
+
*
|
|
4
|
+
* Neither errored; both quietly produced a worse product than the consumer
|
|
5
|
+
* thought they had configured, so both are pinned here rather than left to a
|
|
6
|
+
* reviewer's eye.
|
|
7
|
+
*/
|
|
8
|
+
import { render, screen, fireEvent, act } from '@testing-library/react'
|
|
9
|
+
import { useState } from 'react'
|
|
10
|
+
import { UnifiedTable } from '../../index'
|
|
11
|
+
import { ColumnConfig } from '../../types'
|
|
12
|
+
|
|
13
|
+
interface Row {
|
|
14
|
+
id: string
|
|
15
|
+
name: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const columns: ColumnConfig<Row>[] = [{ id: 'name', header: 'Name', accessorKey: 'name' }]
|
|
19
|
+
|
|
20
|
+
const rows: Row[] = [
|
|
21
|
+
{ id: '1', name: 'Alpha' },
|
|
22
|
+
{ id: '2', name: 'Beta' },
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
describe('SearchConfig.debounceMs', () => {
|
|
26
|
+
beforeEach(() => jest.useFakeTimers())
|
|
27
|
+
afterEach(() => jest.useRealTimers())
|
|
28
|
+
|
|
29
|
+
function Harness({ debounceMs, onSearch }: { debounceMs?: number; onSearch: (v: string) => void }) {
|
|
30
|
+
const [value, setValue] = useState('')
|
|
31
|
+
return (
|
|
32
|
+
<UnifiedTable<Row>
|
|
33
|
+
data={rows}
|
|
34
|
+
columns={columns}
|
|
35
|
+
tableId="t"
|
|
36
|
+
getRowId={(r) => r.id}
|
|
37
|
+
search={{
|
|
38
|
+
enabled: true,
|
|
39
|
+
placeholder: 'Search…',
|
|
40
|
+
value,
|
|
41
|
+
onChange: (next) => {
|
|
42
|
+
setValue(next)
|
|
43
|
+
onSearch(next)
|
|
44
|
+
},
|
|
45
|
+
debounceMs,
|
|
46
|
+
}}
|
|
47
|
+
/>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
it('reports once after the pause, not once per keystroke', () => {
|
|
52
|
+
const onSearch = jest.fn()
|
|
53
|
+
render(<Harness debounceMs={200} onSearch={onSearch} />)
|
|
54
|
+
const input = screen.getByPlaceholderText('Search…') as HTMLInputElement
|
|
55
|
+
|
|
56
|
+
fireEvent.change(input, { target: { value: 'a' } })
|
|
57
|
+
fireEvent.change(input, { target: { value: 'al' } })
|
|
58
|
+
fireEvent.change(input, { target: { value: 'alp' } })
|
|
59
|
+
|
|
60
|
+
// The letters are on screen immediately — the debounce delays the REPORT,
|
|
61
|
+
// never the typing.
|
|
62
|
+
expect(input.value).toBe('alp')
|
|
63
|
+
expect(onSearch).not.toHaveBeenCalled()
|
|
64
|
+
|
|
65
|
+
act(() => { jest.advanceTimersByTime(200) })
|
|
66
|
+
|
|
67
|
+
expect(onSearch).toHaveBeenCalledTimes(1)
|
|
68
|
+
expect(onSearch).toHaveBeenCalledWith('alp')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('does not rewind the box when the owner echoes an earlier value back', () => {
|
|
72
|
+
const onSearch = jest.fn()
|
|
73
|
+
render(<Harness debounceMs={200} onSearch={onSearch} />)
|
|
74
|
+
const input = screen.getByPlaceholderText('Search…') as HTMLInputElement
|
|
75
|
+
|
|
76
|
+
fireEvent.change(input, { target: { value: 'al' } })
|
|
77
|
+
act(() => { jest.advanceTimersByTime(200) })
|
|
78
|
+
// Keep typing while the owner's re-render for "al" lands.
|
|
79
|
+
fireEvent.change(input, { target: { value: 'alpha' } })
|
|
80
|
+
act(() => { jest.advanceTimersByTime(50) })
|
|
81
|
+
|
|
82
|
+
expect(input.value).toBe('alpha')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('accepts a value the owner changed itself', () => {
|
|
86
|
+
function Reset() {
|
|
87
|
+
const [value, setValue] = useState('beta')
|
|
88
|
+
return (
|
|
89
|
+
<>
|
|
90
|
+
<button onClick={() => setValue('')}>clear</button>
|
|
91
|
+
<UnifiedTable<Row>
|
|
92
|
+
data={rows}
|
|
93
|
+
columns={columns}
|
|
94
|
+
tableId="t"
|
|
95
|
+
getRowId={(r) => r.id}
|
|
96
|
+
search={{
|
|
97
|
+
enabled: true,
|
|
98
|
+
placeholder: 'Search…',
|
|
99
|
+
value,
|
|
100
|
+
onChange: setValue,
|
|
101
|
+
debounceMs: 200,
|
|
102
|
+
}}
|
|
103
|
+
/>
|
|
104
|
+
</>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
render(<Reset />)
|
|
108
|
+
const input = screen.getByPlaceholderText('Search…') as HTMLInputElement
|
|
109
|
+
expect(input.value).toBe('beta')
|
|
110
|
+
|
|
111
|
+
fireEvent.click(screen.getByText('clear'))
|
|
112
|
+
expect(input.value).toBe('')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('without debounceMs reports every keystroke, exactly as before', () => {
|
|
116
|
+
const onSearch = jest.fn()
|
|
117
|
+
render(<Harness onSearch={onSearch} />)
|
|
118
|
+
const input = screen.getByPlaceholderText('Search…') as HTMLInputElement
|
|
119
|
+
|
|
120
|
+
fireEvent.change(input, { target: { value: 'a' } })
|
|
121
|
+
fireEvent.change(input, { target: { value: 'al' } })
|
|
122
|
+
|
|
123
|
+
expect(onSearch).toHaveBeenCalledTimes(2)
|
|
124
|
+
expect(onSearch).toHaveBeenLastCalledWith('al')
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('emptyState keeps the way back', () => {
|
|
129
|
+
const empty: Row[] = []
|
|
130
|
+
|
|
131
|
+
it('replaces the whole table when nothing is narrowing the list', () => {
|
|
132
|
+
render(
|
|
133
|
+
<UnifiedTable<Row>
|
|
134
|
+
data={empty}
|
|
135
|
+
columns={columns}
|
|
136
|
+
tableId="t"
|
|
137
|
+
getRowId={(r) => r.id}
|
|
138
|
+
search={{ enabled: true, placeholder: 'Search…', value: '', onChange: () => {} }}
|
|
139
|
+
emptyState={<div>Nothing here yet</div>}
|
|
140
|
+
/>
|
|
141
|
+
)
|
|
142
|
+
expect(screen.getByText('Nothing here yet')).toBeInTheDocument()
|
|
143
|
+
// The unchanged behaviour: no toolbar, because there is nothing to undo.
|
|
144
|
+
expect(screen.queryByPlaceholderText('Search…')).not.toBeInTheDocument()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('keeps the search box when the search is what emptied the list', () => {
|
|
148
|
+
render(
|
|
149
|
+
<UnifiedTable<Row>
|
|
150
|
+
data={empty}
|
|
151
|
+
columns={columns}
|
|
152
|
+
tableId="t"
|
|
153
|
+
getRowId={(r) => r.id}
|
|
154
|
+
search={{ enabled: true, placeholder: 'Search…', value: 'zzz', onChange: () => {} }}
|
|
155
|
+
emptyState={<div>No matches</div>}
|
|
156
|
+
/>
|
|
157
|
+
)
|
|
158
|
+
const input = screen.getByPlaceholderText('Search…') as HTMLInputElement
|
|
159
|
+
expect(input).toBeInTheDocument()
|
|
160
|
+
expect(input.value).toBe('zzz')
|
|
161
|
+
// and the consumer's own copy is still what is shown, in the rows' place.
|
|
162
|
+
expect(screen.getByText('No matches')).toBeInTheDocument()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('keeps the toolbar when a filter is what emptied the list', () => {
|
|
166
|
+
render(
|
|
167
|
+
<UnifiedTable<Row>
|
|
168
|
+
data={empty}
|
|
169
|
+
columns={columns}
|
|
170
|
+
tableId="t"
|
|
171
|
+
getRowId={(r) => r.id}
|
|
172
|
+
search={{ enabled: true, placeholder: 'Search…', value: '', onChange: () => {} }}
|
|
173
|
+
filters={{
|
|
174
|
+
enabled: true,
|
|
175
|
+
position: 'top',
|
|
176
|
+
collapsible: false,
|
|
177
|
+
config: { filters: [{ id: 'status', label: 'Status', type: 'select', options: ['active'] }] },
|
|
178
|
+
value: { status: 'active' },
|
|
179
|
+
onChange: () => {},
|
|
180
|
+
}}
|
|
181
|
+
emptyState={<div>No matches</div>}
|
|
182
|
+
/>
|
|
183
|
+
)
|
|
184
|
+
expect(screen.getByPlaceholderText('Search…')).toBeInTheDocument()
|
|
185
|
+
expect(screen.getByText('No matches')).toBeInTheDocument()
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it("treats TableFilters' own 'all' sentinel as no filter at all", () => {
|
|
189
|
+
render(
|
|
190
|
+
<UnifiedTable<Row>
|
|
191
|
+
data={empty}
|
|
192
|
+
columns={columns}
|
|
193
|
+
tableId="t"
|
|
194
|
+
getRowId={(r) => r.id}
|
|
195
|
+
search={{ enabled: true, placeholder: 'Search…', value: '', onChange: () => {} }}
|
|
196
|
+
filters={{
|
|
197
|
+
enabled: true,
|
|
198
|
+
position: 'top',
|
|
199
|
+
collapsible: false,
|
|
200
|
+
config: { filters: [{ id: 'status', label: 'Status', type: 'select', options: ['active'] }] },
|
|
201
|
+
value: { status: 'all' },
|
|
202
|
+
onChange: () => {},
|
|
203
|
+
}}
|
|
204
|
+
emptyState={<div>Nothing here yet</div>}
|
|
205
|
+
/>
|
|
206
|
+
)
|
|
207
|
+
expect(screen.queryByPlaceholderText('Search…')).not.toBeInTheDocument()
|
|
208
|
+
expect(screen.getByText('Nothing here yet')).toBeInTheDocument()
|
|
209
|
+
})
|
|
210
|
+
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useState, useCallback } from 'react'
|
|
3
|
+
import { ReactNode, useState, useCallback } from 'react'
|
|
4
4
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../ui/table'
|
|
5
5
|
import { Checkbox } from '../../../ui/checkbox'
|
|
6
6
|
import { Button } from '../../../ui/button'
|
|
@@ -51,6 +51,9 @@ interface DataTableCoreProps<TData> {
|
|
|
51
51
|
// Loading
|
|
52
52
|
loadingRows?: Set<string>
|
|
53
53
|
|
|
54
|
+
/** Shown in place of the rows when there are none. Defaults to "No results found." */
|
|
55
|
+
emptyState?: ReactNode
|
|
56
|
+
|
|
54
57
|
className?: string
|
|
55
58
|
}
|
|
56
59
|
|
|
@@ -78,6 +81,7 @@ export function DataTableCore<TData>({
|
|
|
78
81
|
onCellEdit,
|
|
79
82
|
rowActions,
|
|
80
83
|
onRowClick,
|
|
84
|
+
emptyState,
|
|
81
85
|
loadingRows = new Set(),
|
|
82
86
|
className,
|
|
83
87
|
}: DataTableCoreProps<TData>) {
|
|
@@ -341,7 +345,7 @@ export function DataTableCore<TData>({
|
|
|
341
345
|
colSpan={columns.length + (selectionEnabled ? 1 : 0) + (rowActions ? 1 : 0)}
|
|
342
346
|
className="h-24 text-center"
|
|
343
347
|
>
|
|
344
|
-
No results found.
|
|
348
|
+
{emptyState ?? 'No results found.'}
|
|
345
349
|
</TableCell>
|
|
346
350
|
</TableRow>
|
|
347
351
|
) : (
|
|
@@ -13,7 +13,7 @@ import { cn } from '../../../../lib/utils'
|
|
|
13
13
|
import { ExportButton, ExportButtonProps } from './ExportButton'
|
|
14
14
|
import { SavedViewsDropdown, SavedViewsDropdownProps } from './SavedViewsDropdown'
|
|
15
15
|
import { ColumnConfig, ColumnVisibilityState, SavedView } from '../../types'
|
|
16
|
-
import { RefObject } from 'react'
|
|
16
|
+
import { RefObject, useEffect, useRef, useState } from 'react'
|
|
17
17
|
|
|
18
18
|
export interface StandardTableToolbarProps<TData> {
|
|
19
19
|
// Search
|
|
@@ -23,6 +23,13 @@ export interface StandardTableToolbarProps<TData> {
|
|
|
23
23
|
onSearchChange?: (value: string) => void
|
|
24
24
|
searchInputRef?: RefObject<HTMLInputElement | null>
|
|
25
25
|
searchAutoFocus?: boolean
|
|
26
|
+
/**
|
|
27
|
+
* Wait this long after the last keystroke before calling `onSearchChange`.
|
|
28
|
+
* Omitted or 0 keeps the input fully controlled and reports every keystroke,
|
|
29
|
+
* which is what every consumer got before `SearchConfig.debounceMs` was wired
|
|
30
|
+
* up (it was accepted and ignored).
|
|
31
|
+
*/
|
|
32
|
+
searchDebounceMs?: number
|
|
26
33
|
|
|
27
34
|
// Column visibility
|
|
28
35
|
columnVisibilityEnabled?: boolean
|
|
@@ -60,6 +67,7 @@ export function StandardTableToolbar<TData>({
|
|
|
60
67
|
onSearchChange,
|
|
61
68
|
searchInputRef,
|
|
62
69
|
searchAutoFocus,
|
|
70
|
+
searchDebounceMs = 0,
|
|
63
71
|
columnVisibilityEnabled,
|
|
64
72
|
hideableColumns = [],
|
|
65
73
|
columnVisibility = {},
|
|
@@ -76,6 +84,48 @@ export function StandardTableToolbar<TData>({
|
|
|
76
84
|
}: StandardTableToolbarProps<TData>) {
|
|
77
85
|
const hasRightSideButtons = exportEnabled || columnVisibilityEnabled || savedViewsEnabled
|
|
78
86
|
|
|
87
|
+
// DEBOUNCED SEARCH.
|
|
88
|
+
//
|
|
89
|
+
// A debounce cannot sit in front of `onSearchChange` alone: the input's value
|
|
90
|
+
// comes from `searchValue`, so delaying the call would also delay the letter
|
|
91
|
+
// appearing and the box would swallow keystrokes. So the input holds a DRAFT
|
|
92
|
+
// that echoes every keystroke immediately, and only the report upward waits.
|
|
93
|
+
//
|
|
94
|
+
// The draft has to yield to a value the OWNER changed (clearing a search,
|
|
95
|
+
// loading a saved view) but must NOT yield to the owner echoing back what we
|
|
96
|
+
// just sent — that echo arrives a render after the user has typed more, and
|
|
97
|
+
// accepting it would rewind the box mid-word. `emittedRef` is the difference:
|
|
98
|
+
// a prop equal to what we last emitted is our own echo and is ignored.
|
|
99
|
+
const debouncing = searchDebounceMs > 0
|
|
100
|
+
const [draft, setDraft] = useState(searchValue)
|
|
101
|
+
const emittedRef = useRef(searchValue)
|
|
102
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (searchValue === emittedRef.current) return
|
|
106
|
+
emittedRef.current = searchValue
|
|
107
|
+
setDraft(searchValue)
|
|
108
|
+
}, [searchValue])
|
|
109
|
+
|
|
110
|
+
// A pending keystroke must not fire into an unmounted tree.
|
|
111
|
+
useEffect(() => () => {
|
|
112
|
+
if (timerRef.current) clearTimeout(timerRef.current)
|
|
113
|
+
}, [])
|
|
114
|
+
|
|
115
|
+
const handleSearchInput = (value: string) => {
|
|
116
|
+
if (!debouncing) {
|
|
117
|
+
onSearchChange?.(value)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
setDraft(value)
|
|
121
|
+
if (timerRef.current) clearTimeout(timerRef.current)
|
|
122
|
+
timerRef.current = setTimeout(() => {
|
|
123
|
+
timerRef.current = null
|
|
124
|
+
emittedRef.current = value
|
|
125
|
+
onSearchChange?.(value)
|
|
126
|
+
}, searchDebounceMs)
|
|
127
|
+
}
|
|
128
|
+
|
|
79
129
|
return (
|
|
80
130
|
<div className={cn('flex items-center justify-between gap-4', className)}>
|
|
81
131
|
{/* Left side: Search */}
|
|
@@ -84,8 +134,8 @@ export function StandardTableToolbar<TData>({
|
|
|
84
134
|
<Input
|
|
85
135
|
type="text"
|
|
86
136
|
placeholder={searchPlaceholder}
|
|
87
|
-
value={searchValue}
|
|
88
|
-
onChange={(e) =>
|
|
137
|
+
value={debouncing ? draft : searchValue}
|
|
138
|
+
onChange={(e) => handleSearchInput(e.target.value)}
|
|
89
139
|
ref={searchInputRef}
|
|
90
140
|
autoFocus={searchAutoFocus}
|
|
91
141
|
className="max-w-xl w-full"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as XLSX from 'xlsx'
|
|
2
2
|
import { ColumnConfig } from '../types'
|
|
3
|
+
import { csvFromRows, downloadBlob, downloadCsv } from '../../../utils/csv'
|
|
3
4
|
|
|
4
5
|
export type ExportFormat = 'csv' | 'excel'
|
|
5
6
|
|
|
@@ -91,40 +92,8 @@ function prepareExportData<TData>(
|
|
|
91
92
|
return rows
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
*/
|
|
97
|
-
function arrayToCSV(data: string[][]): string {
|
|
98
|
-
return data
|
|
99
|
-
.map(row =>
|
|
100
|
-
row
|
|
101
|
-
.map(cell => {
|
|
102
|
-
// Escape quotes and wrap in quotes if contains comma, quote, or newline
|
|
103
|
-
const needsQuotes = /[,"\n\r]/.test(cell)
|
|
104
|
-
if (needsQuotes) {
|
|
105
|
-
return `"${cell.replace(/"/g, '""')}"`
|
|
106
|
-
}
|
|
107
|
-
return cell
|
|
108
|
-
})
|
|
109
|
-
.join(',')
|
|
110
|
-
)
|
|
111
|
-
.join('\n')
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Triggers a download of the given content
|
|
116
|
-
*/
|
|
117
|
-
function downloadFile(content: string | Blob, filename: string, mimeType: string): void {
|
|
118
|
-
const blob = content instanceof Blob ? content : new Blob([content], { type: mimeType })
|
|
119
|
-
const url = URL.createObjectURL(blob)
|
|
120
|
-
const link = document.createElement('a')
|
|
121
|
-
link.href = url
|
|
122
|
-
link.download = filename
|
|
123
|
-
document.body.appendChild(link)
|
|
124
|
-
link.click()
|
|
125
|
-
document.body.removeChild(link)
|
|
126
|
-
URL.revokeObjectURL(url)
|
|
127
|
-
}
|
|
95
|
+
// Quoting, the formula guard and the line ending all live in one place now —
|
|
96
|
+
// see ../../../utils/csv. A second copy here is how the two drift.
|
|
128
97
|
|
|
129
98
|
/**
|
|
130
99
|
* Exports data to CSV format
|
|
@@ -148,9 +117,8 @@ export function exportToCSV<TData>(
|
|
|
148
117
|
}
|
|
149
118
|
|
|
150
119
|
const exportData = prepareExportData(data, columns, includeHeaders)
|
|
151
|
-
const csv = arrayToCSV(exportData)
|
|
152
120
|
|
|
153
|
-
|
|
121
|
+
downloadCsv(`${filename}.csv`, csvFromRows(exportData))
|
|
154
122
|
}
|
|
155
123
|
|
|
156
124
|
/**
|
|
@@ -198,7 +166,7 @@ export function exportToExcel<TData>(
|
|
|
198
166
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
199
167
|
})
|
|
200
168
|
|
|
201
|
-
|
|
169
|
+
downloadBlob(`${filename}.xlsx`, blob)
|
|
202
170
|
}
|
|
203
171
|
|
|
204
172
|
/**
|