Skip to content

Commit 2359daa

Browse files
authored
Json component and viewer (#163)
1 parent c85030c commit 2359daa

File tree

13 files changed

+304
-18
lines changed

13 files changed

+304
-18
lines changed

packages/cli/public/styles.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ button.close-button:hover {
309309
border-top: none;
310310
}
311311
.file-list a {
312-
border-top: 1px solid #bbb;
312+
border-top: 1px solid #ddd;
313313
color: #444;
314314
display: flex;
315315
padding: 8px 16px 8px 20px;

packages/cli/src/AppComponent.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Page, getHttpSource, getHyperparamSource } from '@hyparam/components'
22
import 'hightable/src/HighTable.css'
33
import React from 'react'
4+
import '@hyparam/components/components.css'
45

56
export default function App() {
67
const search = new URLSearchParams(location.search)

packages/cli/tsconfig.json

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
{
2-
"compilerOptions": {
3-
"allowJs": true,
4-
"checkJs": true,
5-
"jsx": "react",
6-
"lib": ["esnext", "dom"],
7-
"module": "nodenext",
8-
"noEmit": true,
9-
"resolveJsonModule": true,
10-
"strict": true,
11-
},
12-
"include": ["src", "test"],
13-
}
2+
"compilerOptions": {
3+
"allowJs": true,
4+
"checkJs": true,
5+
"jsx": "react",
6+
"lib": ["esnext", "dom"],
7+
"module": "nodenext",
8+
"noEmit": true,
9+
"resolveJsonModule": true,
10+
"strict": true
11+
},
12+
"include": ["src", "test"]
13+
}

packages/components/eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export default tseslint.config(
3737
...tseslint.configs.recommended.rules,
3838
...sharedJsRules,
3939
...sharedTsRules,
40+
'no-extra-parens': 'warn',
4041
},
4142
},
4243
{

packages/components/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
"types": "./es/index.d.ts",
2525
"import": "./dist/index.es.min.js",
2626
"require": "./dist/index.umd.min.js"
27-
}
27+
},
28+
"./components.css": "./dist/components.css"
2829
},
2930
"files": [
3031
"dist",
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { ReactNode, useState } from 'react'
2+
import styles from '../styles/Json.module.css'
3+
4+
interface JsonProps {
5+
json: unknown
6+
label?: string
7+
}
8+
9+
/**
10+
* JSON viewer component with collapsible objects and arrays.
11+
*/
12+
export default function Json({ json, label }: JsonProps): ReactNode {
13+
let div
14+
if (Array.isArray(json)) {
15+
div = <JsonArray array={json} label={label} />
16+
} else if (typeof json === 'object' && json !== null) {
17+
div = <JsonObject label={label} obj={json} />
18+
} else {
19+
// primitive
20+
const key = label ? <span className={styles.key}>{label}: </span> : ''
21+
if (typeof json === 'string') {
22+
div = <>{key}<span className={styles.string}>{JSON.stringify(json)}</span></>
23+
} else if (typeof json === 'number') {
24+
div = <>{key}<span className={styles.number}>{JSON.stringify(json)}</span></>
25+
} else if (typeof json === 'bigint') {
26+
// it's not really json, but show it anyway
27+
div = <>{key}<span>{json.toString()}</span></>
28+
} else {
29+
div = <>{key}<span>{JSON.stringify(json)}</span></>
30+
}
31+
}
32+
return <div className={styles.json}>{div}</div>
33+
}
34+
35+
function JsonArray({ array, label }: { array: unknown[], label?: string }): ReactNode {
36+
const [collapsed, setCollapsed] = useState(false)
37+
const key = label ? <span className={styles.key}>{label}: </span> : ''
38+
if (collapsed) {
39+
return <div className={styles.clickable} onClick={() => { setCollapsed(false) }}>
40+
<span className={styles.drill}>{'\u25B6'}</span>
41+
{key}
42+
<span className={styles.array}>{'[...]'}</span>
43+
</div>
44+
}
45+
return <>
46+
<div className={styles.clickable} onClick={() => { setCollapsed(true) }}>
47+
<span className={styles.drill}>{'\u25BC'}</span>
48+
{key}
49+
<span className={styles.array}>{'['}</span>
50+
</div>
51+
<ul>
52+
{array.map((item, index) => <li key={index}>{<Json json={item} />}</li>)}
53+
</ul>
54+
<div className={styles.array}>{']'}</div>
55+
</>
56+
}
57+
58+
function JsonObject({ obj, label }: { obj: object, label?: string }): ReactNode {
59+
const [collapsed, setCollapsed] = useState(false)
60+
const key = label ? <span className={styles.key}>{label}: </span> : ''
61+
if (collapsed) {
62+
return <div className={styles.clickable} onClick={() => { setCollapsed(false) }}>
63+
<span className={styles.drill}>{'\u25B6'}</span>
64+
{key}
65+
<span className={styles.object}>{'{...}'}</span>
66+
</div>
67+
}
68+
return <>
69+
<div className={styles.clickable} onClick={() => { setCollapsed(true) }}>
70+
<span className={styles.drill}>{'\u25BC'}</span>
71+
{key}
72+
<span className={styles.object}>{'{'}</span>
73+
</div>
74+
<ul>
75+
{Object.entries(obj).map(([key, value]) => (
76+
<li key={key}>
77+
<Json json={value as unknown} label={key} />
78+
</li>
79+
))}
80+
</ul>
81+
<div className={styles.object}>{'}'}</div>
82+
</>
83+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { useEffect, useState } from 'react'
2+
import type { FileSource } from '../../lib/sources/types.js'
3+
import { parseFileSize } from '../../lib/utils.js'
4+
import Json from '../Json.js'
5+
import { Spinner } from '../Layout.js'
6+
import ContentHeader, { TextContent } from './ContentHeader.js'
7+
import styles from '../../styles/Json.module.css'
8+
9+
interface ViewerProps {
10+
source: FileSource
11+
setError: (error: Error | undefined) => void
12+
}
13+
14+
const largeFileSize = 8_000_000 // 8 mb
15+
16+
/**
17+
* JSON viewer component.
18+
*/
19+
export default function JsonView({ source, setError }: ViewerProps) {
20+
const [content, setContent] = useState<TextContent>()
21+
const [json, setJson] = useState<unknown>()
22+
const [isLoading, setIsLoading] = useState(true)
23+
24+
const { resolveUrl, requestInit } = source
25+
26+
// Load json content
27+
useEffect(() => {
28+
async function loadContent() {
29+
try {
30+
setIsLoading(true)
31+
const res = await fetch(resolveUrl, requestInit)
32+
const futureText = res.text()
33+
if (res.status === 401) {
34+
const text = await futureText
35+
setError(new Error(text))
36+
setContent(undefined)
37+
return
38+
}
39+
const fileSize = parseFileSize(res.headers) ?? (await futureText).length
40+
if (fileSize > largeFileSize) {
41+
setError(new Error('File is too large to display'))
42+
setContent(undefined)
43+
return
44+
}
45+
const text = await futureText
46+
setError(undefined)
47+
setContent({ text, fileSize })
48+
setJson(JSON.parse(text))
49+
} catch (error) {
50+
// TODO: show plain text in error case
51+
setError(error as Error)
52+
} finally {
53+
setIsLoading(false)
54+
}
55+
}
56+
void loadContent()
57+
}, [resolveUrl, requestInit, setError])
58+
59+
const headers = content?.text === undefined && <span>Loading...</span>
60+
61+
const isLarge = content?.fileSize && content.fileSize > 1024 * 1024
62+
63+
// If json failed to parse, show the text instead
64+
const showFallbackText = content?.text !== undefined && json === undefined
65+
66+
return <ContentHeader content={content} headers={headers}>
67+
{!isLarge && <>
68+
{!showFallbackText && <code className={styles.jsonView}>
69+
<Json json={json} />
70+
</code>}
71+
{showFallbackText && <code className={styles.text}>
72+
{content.text}
73+
</code>}
74+
</>}
75+
{isLarge && <div className='center'>
76+
File is too large to display
77+
</div>}
78+
79+
{isLoading && <div className='center'><Spinner /></div>}
80+
</ContentHeader>
81+
}

packages/components/src/components/viewers/MarkdownView.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useState } from 'react'
2-
import { FileSource } from '../../lib/sources/types.js'
2+
import type { FileSource } from '../../lib/sources/types.js'
33
import { parseFileSize } from '../../lib/utils.js'
44
import { Spinner } from '../Layout.js'
55
import Markdown from '../Markdown.js'
@@ -25,6 +25,7 @@ export default function MarkdownView({ source, setError }: ViewerProps) {
2525

2626
const { resolveUrl, requestInit } = source
2727

28+
// Load markdown content
2829
useEffect(() => {
2930
async function loadContent() {
3031
try {

packages/components/src/components/viewers/TextView.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@ interface ViewerProps {
1515
*/
1616
export default function TextView({ source, setError }: ViewerProps) {
1717
const [content, setContent] = useState<TextContent>()
18+
const [isLoading, setIsLoading] = useState(true)
1819

1920
const { resolveUrl, requestInit } = source
2021

2122
// Load plain text content
2223
useEffect(() => {
2324
async function loadContent() {
2425
try {
26+
setIsLoading(true)
2527
const res = await fetch(resolveUrl, requestInit)
2628
const text = await res.text()
2729
const fileSize = parseFileSize(res.headers) ?? text.length
@@ -35,6 +37,8 @@ export default function TextView({ source, setError }: ViewerProps) {
3537
} catch (error) {
3638
setError(error as Error)
3739
setContent(undefined)
40+
} finally {
41+
setIsLoading(false)
3842
}
3943
}
4044

@@ -51,7 +55,7 @@ export default function TextView({ source, setError }: ViewerProps) {
5155
{content.text}
5256
</code>}
5357

54-
{!content && <div className='center'><Spinner /></div>}
58+
{isLoading && <div className='center'><Spinner /></div>}
5559
</ContentHeader>
5660
}
5761

packages/components/src/components/viewers/Viewer.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { FileSource } from '../../lib/sources/types.js'
22
import { imageTypes } from '../../lib/utils.js'
33
import ImageView from './ImageView.js'
4+
import JsonView from './JsonView.js'
45
import MarkdownView from './MarkdownView.js'
56
import TableView, { ParquetViewConfig } from './ParquetView.js'
67
import TextView from './TextView.js'
@@ -36,6 +37,8 @@ export default function Viewer({
3637
config={config}
3738
/>
3839
)
40+
} else if (fileName.endsWith('.json')) {
41+
return <JsonView source={source} setError={setError} />
3942
} else if (imageTypes.some((type) => fileName.endsWith(type))) {
4043
return <ImageView source={source} setError={setError} />
4144
}

0 commit comments

Comments
 (0)