Skip to content

Commit abc2cd2

Browse files
adam-christian-softwareAdam Christian
andauthored
fix(console): Allow the console to show Iceberg Views. (#116)
* fix(console): Allow the console to show Iceberg Views. * fix: Resolve TypeScript and React hooks linting errors - Move useMutation hook before early return to comply with React hooks rules - Replace enum with const object to fix erasableSyntaxOnly error - Rename duplicate SchemaField interface to ViewSchemaField to avoid type conflict --------- Co-authored-by: Adam Christian <adam@Adams-MacBook-Pro-2.local>
1 parent 3c9b051 commit abc2cd2

9 files changed

Lines changed: 1152 additions & 14 deletions

File tree

console/src/api/catalog/views.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { apiClient } from "../client"
21+
import type { ListViewsResponse, CreateViewRequest, LoadViewResult } from "@/types/api"
22+
23+
/**
24+
* Encodes a namespace array to URL format.
25+
* Namespace parts are separated by the unit separator character (0x1F).
26+
*/
27+
function encodeNamespace(namespace: string[]): string {
28+
return namespace.join("\x1F")
29+
}
30+
31+
export const viewsApi = {
32+
/**
33+
* List views in a namespace.
34+
* @param prefix - The catalog name (prefix)
35+
* @param namespace - Namespace array (e.g., ["accounting", "tax"])
36+
*/
37+
list: async (
38+
prefix: string,
39+
namespace: string[]
40+
): Promise<Array<{ namespace: string[]; name: string }>> => {
41+
const namespaceStr = encodeNamespace(namespace)
42+
const response = await apiClient
43+
.getCatalogClient()
44+
.get<ListViewsResponse>(
45+
`/${encodeURIComponent(prefix)}/namespaces/${encodeURIComponent(namespaceStr)}/views`
46+
)
47+
return response.data.identifiers
48+
},
49+
50+
/**
51+
* Get view details.
52+
* @param prefix - The catalog name
53+
* @param namespace - Namespace array (e.g., ["accounting", "tax"])
54+
* @param viewName - View name
55+
*/
56+
get: async (
57+
prefix: string,
58+
namespace: string[],
59+
viewName: string
60+
): Promise<LoadViewResult> => {
61+
const namespaceStr = encodeNamespace(namespace)
62+
const response = await apiClient
63+
.getCatalogClient()
64+
.get<LoadViewResult>(
65+
`/${encodeURIComponent(prefix)}/namespaces/${encodeURIComponent(namespaceStr)}/views/${encodeURIComponent(viewName)}`
66+
)
67+
return response.data
68+
},
69+
70+
/**
71+
* Delete a view.
72+
* @param prefix - The catalog name
73+
* @param namespace - Namespace array
74+
* @param viewName - View name
75+
*/
76+
delete: async (
77+
prefix: string,
78+
namespace: string[],
79+
viewName: string
80+
): Promise<void> => {
81+
const namespaceStr = encodeNamespace(namespace)
82+
await apiClient
83+
.getCatalogClient()
84+
.delete(
85+
`/${encodeURIComponent(prefix)}/namespaces/${encodeURIComponent(namespaceStr)}/views/${encodeURIComponent(viewName)}`
86+
)
87+
},
88+
89+
/**
90+
* Create a view in a namespace.
91+
* @param prefix - The catalog name
92+
* @param namespace - Namespace array
93+
* @param request - Create view request body
94+
*/
95+
create: async (
96+
prefix: string,
97+
namespace: string[],
98+
request: CreateViewRequest
99+
): Promise<LoadViewResult> => {
100+
const namespaceStr = encodeNamespace(namespace)
101+
const response = await apiClient
102+
.getCatalogClient()
103+
.post<LoadViewResult>(
104+
`/${encodeURIComponent(prefix)}/namespaces/${encodeURIComponent(namespaceStr)}/views`,
105+
request
106+
)
107+
return response.data
108+
},
109+
}
110+

console/src/app.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { CatalogDetails } from "@/pages/CatalogDetails"
3333
import { NamespaceDetails } from "@/pages/NamespaceDetails"
3434
import { AccessControl } from "@/pages/AccessControl"
3535
import { TableDetails } from "@/pages/TableDetails"
36+
import { ViewDetails } from "@/pages/ViewDetails"
3637

3738
function ThemedToaster() {
3839
const { effectiveTheme } = useTheme()
@@ -70,6 +71,7 @@ function App() {
7071
<Route path="/catalogs/:catalogName" element={<CatalogDetails />} />
7172
<Route path="/catalogs/:catalogName/namespaces/:namespace" element={<NamespaceDetails />} />
7273
<Route path="/catalogs/:catalogName/namespaces/:namespace/tables/:tableName" element={<TableDetails />} />
74+
<Route path="/catalogs/:catalogName/namespaces/:namespace/views/:viewName" element={<ViewDetails />} />
7375
<Route path="/access-control" element={<AccessControl />} />
7476
</Route>
7577
<Route path="*" element={<Navigate to="/" replace />} />

console/src/components/catalog/CatalogExplorer.tsx

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { cn } from "@/lib/utils"
2525
import { CatalogTreeNode, type TreeNode } from "./CatalogTreeNode"
2626
import { catalogsApi } from "@/api/management/catalogs"
2727
import { TableDetailsDrawer } from "./TableDetailsDrawer"
28+
import { ViewDetailsDrawer } from "./ViewDetailsDrawer"
2829
import { useResizableWidth } from "@/hooks/useResizableWidth"
2930
import { CATALOG_NODE_PREFIX } from "@/lib/constants"
3031

@@ -34,10 +35,18 @@ interface CatalogExplorerProps {
3435
className?: string
3536
}
3637

37-
interface SelectedTable {
38+
const CatalogEntityType = {
39+
TABLE: "table",
40+
VIEW: "view",
41+
} as const
42+
43+
type CatalogEntityType = typeof CatalogEntityType[keyof typeof CatalogEntityType]
44+
45+
interface SelectedCatalogEntity {
3846
catalogName: string
3947
namespace: string[]
40-
tableName: string
48+
name: string
49+
type: CatalogEntityType
4150
}
4251

4352
export function CatalogExplorer({
@@ -49,7 +58,7 @@ export function CatalogExplorer({
4958
const [selectedNodeId, setSelectedNodeId] = useState<string>()
5059
const [isCollapsed, setIsCollapsed] = useState(false)
5160
const [drawerOpen, setDrawerOpen] = useState(false)
52-
const [selectedTable, setSelectedTable] = useState<SelectedTable | null>(null)
61+
const [selectedCatalogEntity, setSelectedCatalogEntity] = useState<SelectedCatalogEntity | null>(null)
5362

5463
// Use custom hook for resizable width
5564
const { width, isResizing, handleMouseDown } = useResizableWidth()
@@ -83,9 +92,18 @@ export function CatalogExplorer({
8392
const handleTableClick = useCallback((
8493
catalogName: string,
8594
namespace: string[],
86-
tableName: string
95+
name: string
96+
) => {
97+
setSelectedCatalogEntity({ catalogName, namespace, name, type: CatalogEntityType.TABLE })
98+
setDrawerOpen(true)
99+
}, [])
100+
101+
const handleViewClick = useCallback((
102+
catalogName: string,
103+
namespace: string[],
104+
name: string
87105
) => {
88-
setSelectedTable({ catalogName, namespace, tableName })
106+
setSelectedCatalogEntity({ catalogName, namespace, name, type: CatalogEntityType.VIEW })
89107
setDrawerOpen(true)
90108
}, [])
91109

@@ -179,6 +197,7 @@ export function CatalogExplorer({
179197
onToggleExpand={handleToggleExpand}
180198
onSelectNode={handleSelectNode}
181199
onTableClick={handleTableClick}
200+
onViewClick={handleViewClick}
182201
/>
183202
))}
184203
</div>
@@ -214,15 +233,26 @@ export function CatalogExplorer({
214233
)}
215234

216235
{/* Table Details Drawer */}
217-
{selectedTable && (
236+
{selectedCatalogEntity && selectedCatalogEntity.type === CatalogEntityType.TABLE && (
218237
<TableDetailsDrawer
219238
open={drawerOpen}
220239
onOpenChange={setDrawerOpen}
221-
catalogName={selectedTable.catalogName}
222-
namespace={selectedTable.namespace}
223-
tableName={selectedTable.tableName}
240+
catalogName={selectedCatalogEntity.catalogName}
241+
namespace={selectedCatalogEntity.namespace}
242+
tableName={selectedCatalogEntity.name}
224243
/>
225244
)}
245+
246+
{/* View Details Drawer */}
247+
{selectedCatalogEntity && selectedCatalogEntity.type === CatalogEntityType.VIEW && (
248+
<ViewDetailsDrawer
249+
open={drawerOpen}
250+
onOpenChange={setDrawerOpen}
251+
catalogName={selectedCatalogEntity.catalogName}
252+
namespace={selectedCatalogEntity.namespace}
253+
viewName={selectedCatalogEntity.name}
254+
/>
255+
)}
226256
</>
227257
)
228258
}

console/src/components/catalog/CatalogTreeNode.tsx

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,10 @@ import {
3131
import { cn } from "@/lib/utils"
3232
import { namespacesApi } from "@/api/catalog/namespaces"
3333
import { tablesApi } from "@/api/catalog/tables"
34+
import { viewsApi } from "@/api/catalog/views"
3435
import type { Catalog } from "@/types/api"
3536

36-
export type TreeNodeType = "catalog" | "namespace" | "table"
37+
export type TreeNodeType = "catalog" | "namespace" | "table" | "view"
3738

3839
export interface TreeNode {
3940
type: TreeNodeType
@@ -52,6 +53,7 @@ interface CatalogTreeNodeProps {
5253
onToggleExpand: (nodeId: string) => void
5354
onSelectNode?: (node: TreeNode) => void
5455
onTableClick?: (catalogName: string, namespace: string[], tableName: string) => void
56+
onViewClick?: (catalogName: string, namespace: string[], viewName: string) => void
5557
}
5658

5759
export function CatalogTreeNode({
@@ -62,6 +64,7 @@ export function CatalogTreeNode({
6264
onToggleExpand,
6365
onSelectNode,
6466
onTableClick,
67+
onViewClick,
6568
}: CatalogTreeNodeProps) {
6669
const isExpanded = expandedNodes.has(node.id)
6770
const isSelected = selectedNodeId === node.id
@@ -121,6 +124,22 @@ export function CatalogTreeNode({
121124
currentNamespacePath.length > 0,
122125
})
123126

127+
// Fetch views when namespace is expanded
128+
const viewsQuery = useQuery({
129+
queryKey: [
130+
"views",
131+
node.catalogName || "",
132+
currentNamespacePath.join(".") || "",
133+
],
134+
queryFn: () =>
135+
viewsApi.list(node.catalogName || "", currentNamespacePath),
136+
enabled:
137+
node.type === "namespace" &&
138+
isExpanded &&
139+
!!node.catalogName &&
140+
currentNamespacePath.length > 0,
141+
})
142+
124143
// Fetch generic tables when namespace is expanded
125144
const genericTablesQuery = useQuery({
126145
queryKey: [
@@ -152,6 +171,12 @@ export function CatalogTreeNode({
152171
onTableClick?.(node.catalogName, node.namespace, node.name)
153172
}
154173
onSelectNode?.(node)
174+
} else if (node.type === "view") {
175+
// Open view details when clicking a view
176+
if (node.catalogName && node.namespace && node.namespace.length > 0) {
177+
onViewClick?.(node.catalogName, node.namespace, node.name)
178+
}
179+
onSelectNode?.(node)
155180
}
156181
}
157182

@@ -261,6 +286,21 @@ export function CatalogTreeNode({
261286
parent: node,
262287
})
263288
})
289+
290+
// Add views under namespace
291+
// Views API returns identifiers like [{namespace: ["accounting"], name: "sales_view"}]
292+
const views = viewsQuery.data || []
293+
views.forEach((view) => {
294+
const namespaceId = `${node.id}.view.${view.name}`
295+
children.push({
296+
type: "view",
297+
id: namespaceId,
298+
name: view.name,
299+
namespace: currentNamespacePath, // Full namespace path where view resides
300+
catalogName: node.catalogName,
301+
parent: node,
302+
})
303+
})
264304
}
265305

266306
return children
@@ -269,14 +309,15 @@ export function CatalogTreeNode({
269309
namespacesQuery.data,
270310
childNamespacesQuery.data,
271311
tablesQuery.data,
312+
viewsQuery.data,
272313
genericTablesQuery.data,
273314
currentNamespacePath,
274315
])
275316

276317
const isLoading =
277318
(node.type === "catalog" && namespacesQuery.isLoading) ||
278319
(node.type === "namespace" &&
279-
(childNamespacesQuery.isLoading || tablesQuery.isLoading || genericTablesQuery.isLoading))
320+
(childNamespacesQuery.isLoading || tablesQuery.isLoading || viewsQuery.isLoading || genericTablesQuery.isLoading))
280321

281322
const Icon = useMemo(() => {
282323
if (node.type === "catalog") return Database
@@ -327,11 +368,12 @@ export function CatalogTreeNode({
327368
onToggleExpand={onToggleExpand}
328369
onSelectNode={onSelectNode}
329370
onTableClick={onTableClick}
371+
onViewClick={onViewClick}
330372
/>
331373
))}
332-
{!isLoading && childNodes.length === 0 && node.type !== "table" && (
374+
{!isLoading && childNodes.length === 0 && node.type !== "table" && node.type !== "view" && (
333375
<div className="px-2 py-1 text-xs text-muted-foreground italic">
334-
No {node.type === "catalog" ? "namespaces" : "tables"} found
376+
No {node.type === "catalog" ? "namespaces" : "items"} found
335377
</div>
336378
)}
337379
</div>

0 commit comments

Comments
 (0)