Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hyperparam",
"version": "0.2.0",
"version": "0.2.1",
"description": "Hyperparam CLI",
"license": "MIT",
"main": "src/cli.js",
Expand All @@ -23,7 +23,7 @@
},
"dependencies": {
"highlight.js": "11.10.0",
"@hyparam/components": "0.1.0",
"@hyparam/components": "0.1.1",
"react": "18.3.1",
"react-dom": "18.3.1"
},
Expand Down
15 changes: 12 additions & 3 deletions packages/cli/public/build/app.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/cli/public/build/app.min.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/cli/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ const app = document.getElementById('app')
if (!app) throw new Error('missing app element')

const root = ReactDOM.createRoot(app)
root.render(React.createElement(App))
root.render(React.createElement(App, { apiBaseUrl: location.origin }))
4 changes: 2 additions & 2 deletions packages/components/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hyparam/components",
"version": "0.1.0",
"version": "0.1.1",
"description": "React components for hyparam apps",
"keywords": [
"component",
Expand Down Expand Up @@ -38,7 +38,7 @@
"test": "vitest run"
},
"dependencies": {
"@hyparam/utils": "0.1.0",
"@hyparam/utils": "0.1.1",
"hightable": "0.6.3",
"hyparquet": "1.5.0",
"hyparquet-compressors": "0.1.4"
Expand Down
8 changes: 6 additions & 2 deletions packages/components/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import Page from './Page.js'

export default function App() {
interface AppProps {
apiBaseUrl: string
}

export default function App(props: AppProps) {
return (
<Page />
<Page {...props} />
)
}
11 changes: 5 additions & 6 deletions packages/components/src/Folder.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { FileMetadata, FolderKey, cn, getFileDate, getFileDateShort, getFileSize, listFiles } from '@hyparam/utils'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import Layout, { Spinner } from './Layout.js'

interface FolderProps {
Expand All @@ -13,21 +13,20 @@ export default function Folder({ folderKey }: FolderProps) {
// State to hold file listing
const [files, setFiles] = useState<FileMetadata[]>()
const [error, setError] = useState<Error>()
const listRef = useRef<HTMLUListElement>(null)

// Folder path from url
const { prefix } = folderKey
const { prefix, listFilesUrl } = folderKey
const path = prefix.split('/')

// Fetch files on component mount
useEffect(() => {
listFiles(prefix)
listFiles(listFilesUrl)
.then(setFiles)
.catch((error: unknown) => {
setFiles([])
setError(error instanceof Error ? error : new Error(`Failed to fetch files - ${error}`))
})
}, [prefix])
}, [listFilesUrl])

const fileUrl = useCallback((file: FileMetadata) => {
return prefix ? `/files?key=${prefix}/${file.key}` : `/files?key=${file.key}`
Expand All @@ -43,7 +42,7 @@ export default function Folder({ folderKey }: FolderProps) {
</div>
</nav>

{files && files.length > 0 && <ul className='file-list' ref={listRef}>
{files && files.length > 0 && <ul className='file-list'>
{files.map((file, index) =>
<li key={index}>
<a href={fileUrl(file)}>
Expand Down
8 changes: 6 additions & 2 deletions packages/components/src/Page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ import Cell from './Cell.js'
import File from './File.js'
import Folder from './Folder.js'

export default function Page() {
interface PageProps {
apiBaseUrl: string
}

export default function Page({ apiBaseUrl }: PageProps) {
const search = new URLSearchParams(location.search)
const key = search.get('key')
if (Array.isArray(key)) throw new Error('key must be a string')

const parsedKey = parseKey(key)
const parsedKey = parseKey(key, { apiBaseUrl } )

// row, col from url
const row = search.get('row')
Expand Down
4 changes: 2 additions & 2 deletions packages/components/test/File.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ global.fetch = vi.fn(() => Promise.resolve({ text: vi.fn() } as unknown as Respo

describe('File Component', () => {
it('renders a local file path', async () => {
const parsedKey = parseKey('folder/subfolder/test.txt')
const parsedKey = parseKey('folder/subfolder/test.txt', { apiBaseUrl: 'http://localhost:3000' })
assert(parsedKey.kind === 'file')

const { getByText } = await act(() => render(
Expand All @@ -34,7 +34,7 @@ describe('File Component', () => {
})

it('renders correct breadcrumbs for nested folders', async () => {
const parsedKey = parseKey('folder1/folder2/folder3/test.txt')
const parsedKey = parseKey('folder1/folder2/folder3/test.txt', { apiBaseUrl: 'http://localhost:3000' })
assert(parsedKey.kind === 'file')
const { getAllByRole } = await act(() => render(
<File parsedKey={parsedKey} />,
Expand Down
10 changes: 5 additions & 5 deletions packages/components/test/Folder.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ const mockFiles: FileMetadata[] = [
describe('Folder Component', () => {
it('fetches file data and displays files on mount', async () => {
vi.mocked(listFiles).mockResolvedValueOnce(mockFiles)
const folderKey = parseKey('')
const folderKey = parseKey('', { apiBaseUrl: 'http://localhost:3000' })
assert(folderKey.kind === 'folder')
const { findByText, getByText } = render(<Folder folderKey={folderKey} />)

await waitFor(() => {expect(listFiles).toHaveBeenCalledWith('')})
await waitFor(() => {expect(listFiles).toHaveBeenCalledWith('http://localhost:3000/api/store/list?prefix=')})

const folderLink = await findByText('folder1/')
expect(folderLink.closest('a')?.getAttribute('href')).toBe('/files?key=folder1/')
Expand All @@ -46,7 +46,7 @@ describe('Folder Component', () => {

it('displays the spinner while loading', () => {
vi.mocked(listFiles).mockReturnValue(new Promise(() => []))
const folderKey = parseKey('test-prefix/')
const folderKey = parseKey('test-prefix/', { apiBaseUrl: 'http://localhost:3000' })
assert(folderKey.kind === 'folder')
const { container } = render(<Folder folderKey={folderKey} />)
expect(container.querySelector('.spinner')).toBeDefined()
Expand All @@ -55,7 +55,7 @@ describe('Folder Component', () => {
it('handles file listing errors', async () => {
const errorMessage = 'Failed to fetch'
vi.mocked(listFiles).mockRejectedValue(new Error(errorMessage))
const folderKey = parseKey('test-prefix/')
const folderKey = parseKey('test-prefix/', { apiBaseUrl: 'http://localhost:3000' })
assert(folderKey.kind === 'folder')
const { findByText, queryByText } = render(<Folder folderKey={folderKey} />)

Expand All @@ -68,7 +68,7 @@ describe('Folder Component', () => {

it('renders breadcrumbs correctly', async () => {
vi.mocked(listFiles).mockResolvedValue(mockFiles)
const folderKey = parseKey('subdir1/subdir2/')
const folderKey = parseKey('subdir1/subdir2/', { apiBaseUrl: 'http://localhost:3000' })
assert(folderKey.kind === 'folder')
const { findByText, getByText } = render(<Folder folderKey={folderKey} />)
await waitFor(() => { expect(listFiles).toHaveBeenCalled() })
Expand Down
6 changes: 4 additions & 2 deletions packages/components/test/viewers/ImageView.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { FileKey, parseKey } from '@hyparam/utils'
import { parseKey } from '@hyparam/utils'
import { render } from '@testing-library/react'
import { strict as assert } from 'assert'
import React from 'react'
import { describe, expect, it, vi } from 'vitest'
import ImageView from '../../src/viewers/ImageView.js'
Expand All @@ -13,7 +14,8 @@ describe('ImageView Component', () => {
arrayBuffer: () => Promise.resolve(body),
headers: new Map([['content-length', body.byteLength]]),
} as unknown as Response)
const parsedKey = parseKey('test.png') as FileKey
const parsedKey = parseKey('test.png', { apiBaseUrl: 'http://localhost:3000' })
assert(parsedKey.kind === 'file')

const { findByRole, findByText } = render(
<ImageView parsedKey={parsedKey} setError={console.error} />,
Expand Down
6 changes: 4 additions & 2 deletions packages/components/test/viewers/MarkdownView.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { FileKey, parseKey } from '@hyparam/utils'
import { parseKey } from '@hyparam/utils'
import { render } from '@testing-library/react'
import { strict as assert } from 'assert'
import React from 'react'
import { describe, expect, it, vi } from 'vitest'
import MarkdownView from '../../src/viewers/MarkdownView.js'
Expand All @@ -13,7 +14,8 @@ describe('MarkdownView Component', () => {
text: () => Promise.resolve(text),
headers: new Map([['content-length', text.length]]),
} as unknown as Response)
const parsedKey = parseKey('test.md') as FileKey
const parsedKey = parseKey('test.md', { apiBaseUrl: 'http://localhost:3000' })
assert(parsedKey.kind === 'file')

const { findByText } = render(
<MarkdownView parsedKey={parsedKey} setError={console.error} />,
Expand Down
2 changes: 1 addition & 1 deletion packages/utils/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hyparam/utils",
"version": "0.1.0",
"version": "0.1.1",
"description": "Utility functions for hyparam apps",
"keywords": [
"data",
Expand Down
9 changes: 4 additions & 5 deletions packages/utils/src/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,14 @@ export interface FileContent<T> {
contentRange?: string
}


/**
* List user files from server
*
* @param prefix file path prefix
* @param url API URL to fetch folder files from
*/
export async function listFiles(prefix: string, recursive?: boolean): Promise<FileMetadata[]> {
const rec = recursive ? '&recursive=true' : ''
prefix = encodeURIComponent(prefix)
const res = await fetch(`/api/store/list?prefix=${prefix}${rec}`)
export async function listFiles(url: string): Promise<FileMetadata[]> {
const res = await fetch(url)
if (res.ok) {
return await res.json() as FileMetadata[]
} else {
Expand Down
40 changes: 29 additions & 11 deletions packages/utils/src/key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,30 +13,48 @@ export interface UrlKey {
export interface FolderKey {
kind: 'folder';
raw: string | null;
listFilesUrl: string;
prefix: string;
}

export type ParsedKey = FileKey | UrlKey | FolderKey;

export function parseKey(raw: string | null): ParsedKey {
if (!raw) {
return { kind: 'folder', raw, prefix: '' }
}
const key = decodeURIComponent(raw)
if (key.endsWith('/')) {
const prefix = key.replace(/\/$/, '')
return { kind: 'folder', raw, prefix }
}
function getFolderListFilesUrl(prefix: string, apiBaseUrl?: string): string {
if (!apiBaseUrl) throw new Error('apiBaseUrl is required')
const url = new URL( '/api/store/list', apiBaseUrl )
url.searchParams.append('prefix', encodeURIComponent(prefix))
return url.toString()
}

function getFileResolveUrl(key: string, apiBaseUrl?: string): string {
if (!apiBaseUrl) throw new Error('apiBaseUrl is required')
const url = new URL( '/api/store/get', apiBaseUrl )
url.searchParams.append('key', encodeURIComponent(key))
return url.toString()
}

function getFilename(key: string): string {
const fileName = key
.replace(/\?.*$/, '') // remove query string
.split('/')
.at(-1)
if (!fileName) throw new Error('Invalid key')
return fileName
}

export function parseKey(raw: string | null, { apiBaseUrl } : { apiBaseUrl?: string } = {}): ParsedKey {
if (!raw) {
const prefix = ''
return { kind: 'folder', raw, prefix, listFilesUrl: getFolderListFilesUrl(prefix, apiBaseUrl) }
}
const key = decodeURIComponent(raw)
if (key.endsWith('/')) {
const prefix = key.replace(/\/$/, '')
return { kind: 'folder', raw, prefix, listFilesUrl: getFolderListFilesUrl(prefix, apiBaseUrl) }
}
const fileName = getFilename(key)
if (key.startsWith('http://') || key.startsWith('https://')) {
return { kind: 'url', raw, resolveUrl: key, fileName }
}
const resolveUrl = '/api/store/get?key=' + key
return { kind: 'file', raw, resolveUrl, fileName }
return { kind: 'file', raw, fileName, resolveUrl: getFileResolveUrl(key, apiBaseUrl) }
}
Loading