@startsimpli/ui 0.4.104 → 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 +4 -3
- 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
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `downloadCsv` writes the BOM. Without it Excel reads a UTF-8 file in the local
|
|
3
|
+
* ANSI codepage and every accented character arrives mojibaked — which is what
|
|
4
|
+
* the table's exporter did until these primitives replaced its private copy.
|
|
5
|
+
*/
|
|
6
|
+
import { downloadCsv } from '../csv';
|
|
7
|
+
|
|
8
|
+
describe('downloadCsv', () => {
|
|
9
|
+
const originalCreate = URL.createObjectURL
|
|
10
|
+
const originalRevoke = URL.revokeObjectURL
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
URL.createObjectURL = originalCreate
|
|
14
|
+
URL.revokeObjectURL = originalRevoke
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('prefixes the UTF-8 BOM and names the file as asked', () => {
|
|
18
|
+
let captured: Blob | null = null
|
|
19
|
+
URL.createObjectURL = ((blob: Blob) => {
|
|
20
|
+
captured = blob
|
|
21
|
+
return 'blob:x'
|
|
22
|
+
}) as typeof URL.createObjectURL
|
|
23
|
+
URL.revokeObjectURL = (() => {}) as typeof URL.revokeObjectURL
|
|
24
|
+
|
|
25
|
+
const clicks: string[] = []
|
|
26
|
+
const realCreateElement = document.createElement.bind(document)
|
|
27
|
+
jest.spyOn(document, 'createElement').mockImplementation((tag: string) => {
|
|
28
|
+
const el = realCreateElement(tag) as HTMLAnchorElement
|
|
29
|
+
if (tag === 'a') el.click = () => clicks.push(el.download)
|
|
30
|
+
return el
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
downloadCsv('patents-2026-08-24.csv', 'Name\r\nCafé\r\n')
|
|
34
|
+
|
|
35
|
+
expect(clicks).toEqual(['patents-2026-08-24.csv'])
|
|
36
|
+
expect(captured).not.toBeNull()
|
|
37
|
+
expect(captured!.type).toBe('text/csv;charset=utf-8;')
|
|
38
|
+
;(document.createElement as jest.Mock).mockRestore()
|
|
39
|
+
})
|
|
40
|
+
})
|
package/src/utils/csv.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSV, written the way a spreadsheet actually reads it.
|
|
3
|
+
*
|
|
4
|
+
* These are the primitives `unified-table/utils/export.ts` uses, and they are
|
|
5
|
+
* exported so a page that needs to export something the table does not hold —
|
|
6
|
+
* every matching row rather than the page on screen — writes the SAME file
|
|
7
|
+
* rather than growing a second CSV implementation next to this one.
|
|
8
|
+
*
|
|
9
|
+
* THE FORMULA GUARD IS A SECURITY CONTROL, not a nicety. A cell whose first
|
|
10
|
+
* character is `=`, `+`, `-` or `@` is EXECUTED by Excel and Sheets when the
|
|
11
|
+
* file is opened, so anyone who can name a record can run a formula in whoever
|
|
12
|
+
* exports the list. The leading apostrophe is the standard neutralisation.
|
|
13
|
+
*
|
|
14
|
+
* THE BOM IS WHY AN ACCENT SURVIVES. Without it Excel reads a UTF-8 file in the
|
|
15
|
+
* local ANSI codepage and a title with an accent arrives mojibaked.
|
|
16
|
+
*
|
|
17
|
+
* CRLF is the line ending RFC 4180 specifies and the one Excel does not
|
|
18
|
+
* second-guess when a quoted cell contains a newline of its own.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Characters that make a spreadsheet treat a cell as a formula. Tab and CR are
|
|
22
|
+
* in the set because they can be used to smuggle a leader past a naive check. */
|
|
23
|
+
const FORMULA_LEADERS = new Set(['=', '+', '-', '@', '\t', '\r'])
|
|
24
|
+
|
|
25
|
+
/** One cell: formula-guarded, then quoted if it needs to be. */
|
|
26
|
+
export function csvCell(raw: unknown): string {
|
|
27
|
+
const value = raw == null ? '' : String(raw)
|
|
28
|
+
const guarded = value.length > 0 && FORMULA_LEADERS.has(value[0]) ? `'${value}` : value
|
|
29
|
+
if (/[",\n\r]/.test(guarded)) return `"${guarded.replace(/"/g, '""')}"`
|
|
30
|
+
return guarded
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Rows of already-stringified cells -> one RFC 4180 document. */
|
|
34
|
+
export function csvFromRows(rows: string[][]): string {
|
|
35
|
+
return `${rows.map((row) => row.map(csvCell).join(',')).join('\r\n')}\r\n`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CsvColumn<T> {
|
|
39
|
+
header: string
|
|
40
|
+
value: (row: T) => string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Typed rows + a column spec -> one RFC 4180 document. */
|
|
44
|
+
export function toCsv<T>(rows: T[], columns: CsvColumn<T>[]): string {
|
|
45
|
+
const out = [columns.map((c) => c.header)]
|
|
46
|
+
for (const row of rows) out.push(columns.map((c) => c.value(row)))
|
|
47
|
+
return csvFromRows(out)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `products-2026-08-24.csv` — the subject and the day, so two exports never
|
|
51
|
+
* collide in a downloads folder. */
|
|
52
|
+
export function csvFilename(base: string, now: Date = new Date()): string {
|
|
53
|
+
const slug =
|
|
54
|
+
base.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'export'
|
|
55
|
+
return `${slug}-${now.toISOString().slice(0, 10)}.csv`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Hand a file to the browser. A Blob + object URL rather than a `data:` URI: a
|
|
60
|
+
* large list exceeds what some browsers accept in a URL, and the object URL is
|
|
61
|
+
* revoked immediately so the rows do not sit in memory after the save.
|
|
62
|
+
*/
|
|
63
|
+
export function downloadBlob(filename: string, blob: Blob): void {
|
|
64
|
+
const url = URL.createObjectURL(blob)
|
|
65
|
+
const link = document.createElement('a')
|
|
66
|
+
link.href = url
|
|
67
|
+
link.download = filename
|
|
68
|
+
document.body.appendChild(link)
|
|
69
|
+
link.click()
|
|
70
|
+
document.body.removeChild(link)
|
|
71
|
+
URL.revokeObjectURL(url)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Save a CSV document, BOM and all. */
|
|
75
|
+
export function downloadCsv(filename: string, csv: string): void {
|
|
76
|
+
downloadBlob(filename, new Blob([`${csv}`], { type: 'text/csv;charset=utf-8;' }))
|
|
77
|
+
}
|