Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

schema explorer as a tree and search #236

Merged
merged 8 commits into from
Nov 12, 2024
Merged
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
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"js-cookie": "^3.0.5",
"jsonwebtoken": "^9.0.2",
"katex": "^0.16.11",
"levenshtein": "^1.0.5",
"lodash.debounce": "^4.0.8",
"lru-cache": "^10.2.2",
"lucide-react": "^0.378.0",
Expand Down Expand Up @@ -134,6 +135,7 @@
"@types/formidable": "^3.4.5",
"@types/jest": "^29.5.12",
"@types/jsonwebtoken": "^9.0.5",
"@types/levenshtein": "^1.0.4",
"@types/lodash.debounce": "^4.0.9",
"@types/node": "^20",
"@types/papaparse": "^5.3.10",
Expand Down
55 changes: 33 additions & 22 deletions apps/web/src/components/schemaExplorer/DatabaseList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { ChevronRightIcon } from '@heroicons/react/24/outline'
import { ExclamationTriangleIcon } from '@heroicons/react/24/solid'
import { isDataSourceStructureLoading } from '@briefer/types'
import { useMemo } from 'react'
import ScrollBar from '../ScrollBar'
import * as dfns from 'date-fns'

interface Props {
dataSources: APIDataSources
Expand All @@ -26,45 +28,54 @@ export default function DatabaseList(props: Props) {
onRetrySchema={() => {}}
canRetrySchema={false}
/>
<div className="flex-grow text-sm text-gray-500 font-sans font-medium overflow-y-auto border-t border-gray-200 mt-4">
<ScrollBar className="flex-grow text-sm text-gray-500 font-sans font-medium overflow-y-auto border-t border-gray-200 mt-4">
<ul className="h-full">
{sortedDataSources.map((dataSource) => {
return (
<li
key={dataSource.config.data.id}
className="px-4 xl:px-6 py-2 border-b border-gray-200 cursor-pointer hover:bg-gray-50 flex items-center justify-between"
className="px-4 xl:px-6 py-2 border-b border-gray-200 cursor-pointer hover:bg-gray-50 flex gap-y-2 gap-x-3 items-center justify-between"
onClick={() =>
props.onSelectDataSource(dataSource.config.data.id)
}
>
<div className="flex gap-x-2.5 items-center font-mono text-xs">
<img
src={databaseImages(dataSource.config.type)}
alt=""
className="h-4 w-4 text-red-600"
/>
<img
src={databaseImages(dataSource.config.type)}
alt=""
className="h-6 w-6 text-red-600"
/>

<div className="flex flex-col justify-left w-full gap-y-0.5">
<h4>{dataSource.config.data.name}</h4>
</div>
<div className="flex gap-x-1 items-center">
{isDataSourceStructureLoading(dataSource.structure) ? (
<span className="font-normal text-xs text-gray-400 animate-pulse">
Refreshing...
</span>
) : dataSource.structure.status === 'failed' ? (
<>
<ExclamationTriangleIcon className="h-3 w-3 text-yellow-400/70" />
<div className="flex gap-x-1 items-center">
{isDataSourceStructureLoading(dataSource.structure) ? (
<span className="font-normal text-xs text-gray-400 animate-pulse">
Refreshing...
</span>
) : dataSource.structure.status === 'failed' ? (
<>
<ExclamationTriangleIcon className="h-3 w-3 text-yellow-400/70" />
<span className="font-normal text-xs text-gray-400">
Schema not loaded
</span>
</>
) : dataSource.structure.status === 'success' ? (
<span className="font-normal text-xs text-gray-400">
Schema not loaded
Last updated{' '}
{dfns.formatDistanceToNow(
dataSource.structure.updatedAt
)}
</span>
</>
) : null}
<ChevronRightIcon className="h-3 w-3 text-gray-500" />
) : null}
</div>
</div>

<ChevronRightIcon className="h-3 w-3 text-gray-500" />
</li>
)
})}
</ul>
</div>
</ScrollBar>
</div>
)
}
2 changes: 1 addition & 1 deletion apps/web/src/components/schemaExplorer/SchemaInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function SchemaInfo(props: SchemaInfoProps): JSX.Element | null {

function SchemaInfoLoading({ refreshing = false }: { refreshing?: boolean }) {
return (
<div className="font-normal w-full flex justify-center py-2 items-center gap-x-1.5 text-sm bg-white border-b border-gray-200">
<div className="font-normal w-full flex justify-center py-2 items-center gap-x-2 text-xs border-b border-gray-200 bg-white animate-pulse">
<Spin />
<span>{refreshing ? 'Refreshing' : 'Loading'} schema...</span>
</div>
Expand Down
164 changes: 134 additions & 30 deletions apps/web/src/components/schemaExplorer/SchemaList.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,66 @@
import Levenshtein from 'levenshtein'
import { Map } from 'immutable'
import ExplorerTitle from './ExplorerTitle'
import { databaseImages } from '../DataSourcesList'
import { ChevronRightIcon } from '@heroicons/react/24/outline'
import {
ChevronDownIcon,
ChevronRightIcon,
MagnifyingGlassIcon,
} from '@heroicons/react/24/outline'
import { ChevronLeftIcon } from '@heroicons/react/24/solid'
import { ShapesIcon } from 'lucide-react'
import type { DataSource, APIDataSource } from '@briefer/database'
import { useMemo } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { SchemaInfo } from './SchemaInfo'
import TableList from './TableList'
import { DataSourceSchema } from '@briefer/types'
import ScrollBar from '../ScrollBar'
import { useDebounce } from '@/hooks/useDebounce'

interface Props {
schemaNames: string[]
schemas: Map<string, DataSourceSchema>
dataSource: APIDataSource
onSelectSchema: (schemaName: string) => void
onBack: () => void
onRetrySchema: (dataSource: DataSource) => void
canRetrySchema: boolean
}
export default function SchemaList(props: Props) {
const sortedSchemaNames = useMemo(
() => props.schemaNames.sort((a, b) => a.localeCompare(b)),
[props.schemaNames]
const [search, setSearch] = useState('')
const onChangeSearch = useDebounce(
(e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value)
},
500,
[]
)

const sortedSchemas = useMemo(
() =>
Array.from(props.schemas.entries())
.filter(([schemaName, schema]) => {
const columns = Object.entries(schema.tables).flatMap(
([tableName, table]) =>
table.columns.flatMap(
(column) => `${schemaName}.${tableName}.${column.name}`
)
)

return columns.some(
(column) =>
search.trim() === '' ||
column
.trim()
.toLowerCase()
.includes(search.trim().toLowerCase()) ||
new Levenshtein(
column.trim().toLowerCase(),
search.trim().toLowerCase()
).distance <=
column.length / 2
)
})
.sort(([a], [b]) => a.localeCompare(b)),
[props.schemas, search]
)

return (
Expand All @@ -33,45 +75,107 @@ export default function SchemaList(props: Props) {

<div className="pt-4 flex flex-col h-full overflow-hidden">
<button
className="relative flex px-4 py-2 text-xs font-medium border-y bg-gray-50 text-gray-600 items-center justify-between font-mono hover:bg-gray-100 group w-full"
className="relative flex px-4 py-2 text-xs font-medium border-y bg-gray-50 items-center justify-between font-mono hover:bg-gray-100 group w-full"
onClick={props.onBack}
>
<div className="flex gap-x-1.5 items-center">
<div className="flex gap-x-1.5 items-center overflow-hidden">
<ChevronLeftIcon className="h-3 w-3 text-gray-500 group-hover:text-gray-700" />
<h4>{props.dataSource.config.data.name}</h4>
<ScrollBar className="text-left overflow-auto horizontal-only whitespace-nowrap">
<h4>{props.dataSource.config.data.name}</h4>
</ScrollBar>
</div>

<img
src={databaseImages(props.dataSource.config.type)}
alt=""
className="h-4 w-4 group-hover:grayscale-[50%]"
/>
<div className="pl-1">
<img
src={databaseImages(props.dataSource.config.type)}
alt=""
className="h-4 w-4 group-hover:grayscale-[50%]"
/>
</div>
</button>

<div className="flex-grow text-xs text-gray-500 font-sans font-medium overflow-y-auto">
<div className="flex-grow text-xs font-sans font-medium overflow-y-auto">
<SchemaInfo
dataSource={props.dataSource}
onRetrySchema={props.onRetrySchema}
/>
<div className="px-4 py-0 flex items-center border-b border-gray-200 group focus-within:border-blue-300">
<MagnifyingGlassIcon className="h-3.5 w-3.5 text-gray-400 group-focus-within:text-blue-500" />
<input
type="text"
placeholder="Search..."
className="w-full h-8 border-0 placeholder-gray-400 text-xs text-gray-600 focus:outline-none focus:ring-0 pl-2"
onChange={onChangeSearch}
/>
</div>
<ul className="h-full">
{sortedSchemaNames.map((schemaName) => {
return (
<li
{sortedSchemas.length === 0 ? (
<li className="px-4 py-4 text-gray-500 text-xs">
No results found.
</li>
) : (
sortedSchemas.map(([schemaName, schema]) => (
<SchemaItem
key={schemaName}
className="px-4 xl:px-6 py-2.5 border-b border-gray-200 cursor-pointer hover:bg-gray-50 flex items-center justify-between"
onClick={() => props.onSelectSchema(schemaName)}
>
<div className="flex gap-x-1.5 items-center font-mono">
<ShapesIcon className="text-gray-400 h-4 w-4" />
<h4>{schemaName}</h4>
</div>
<ChevronRightIcon className="h-3 w-3 text-gray-500" />
</li>
)
})}
dataSource={props.dataSource}
schemaName={schemaName}
schema={schema}
search={search}
/>
))
)}
</ul>
</div>
</div>
</div>
)
}

interface SchemaItemProps {
dataSource: APIDataSource
schemaName: string
schema: DataSourceSchema
search: string
}
function SchemaItem(props: SchemaItemProps) {
const [open, setOpen] = useState(false)
const onToggleOpen = useCallback(() => {
setOpen(!open)
}, [open])
useEffect(() => {
if (props.search) {
setOpen(true)
}
}, [props.search])

return (
<li key={props.schemaName}>
<button
className="px-3.5 py-2 cursor-pointer hover:bg-gray-50 flex items-center justify-between w-full font-normal"
onClick={onToggleOpen}
>
<div className="flex gap-x-1.5 items-center overflow-hidden">
<ShapesIcon className="h-3.5 w-3.5 text-gray-400" />
<ScrollBar className="text-left overflow-auto horizontal-only whitespace-nowrap flex-auto">
<h4>{props.schemaName}</h4>
</ScrollBar>
</div>
<div className="pl-1">
{open ? (
<ChevronDownIcon className="h-3 w-3 text-gray-500" />
) : (
<ChevronRightIcon className="h-3 w-3 text-gray-500" />
)}
</div>
</button>
<div className={open ? 'block' : 'hidden'}>
<TableList
dataSource={props.dataSource}
schemaName={props.schemaName}
schema={props.schema}
search={props.search}
/>
</div>
</li>
)
}
Loading