This is an automated email from the ASF dual-hosted git repository.

mchades pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/branch-1.3 by this push:
     new afb99db8b3 [Cherry-pick to branch-1.3] [#11245] web-v2(Improvement): 
UI supports hierarchical schemasupport (#11257) (#11437)
afb99db8b3 is described below

commit afb99db8b3f4c6be415a4811251a0566f9f59f4f
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Jun 5 09:50:41 2026 +0800

    [Cherry-pick to branch-1.3] [#11245] web-v2(Improvement): UI supports 
hierarchical schemasupport (#11257) (#11437)
    
    **Cherry-pick Information:**
    - Original commit: 578355911b8af551c160a21558c43425ffe410aa
    - Target branch: `branch-1.3`
    - Status: ✅ Clean cherry-pick (no conflicts)
    
    Co-authored-by: Qian Xia <[email protected]>
---
 .../catalogs/rightContent/CreateSchemaDialog.js    |  21 +-
 .../entitiesContent/CatalogDetailsPage.js          | 122 ++---------
 .../entitiesContent/SchemaDetailsPage.js           | 240 ++++++++++++++++++---
 .../entitiesContent/SharedSchemaColumns.js         | 133 ++++++++++++
 web-v2/web/src/lib/api/schemas/index.js            |   5 +-
 web-v2/web/src/lib/store/metalakes/index.js        |  87 ++++++--
 6 files changed, 447 insertions(+), 161 deletions(-)

diff --git a/web-v2/web/src/app/catalogs/rightContent/CreateSchemaDialog.js 
b/web-v2/web/src/app/catalogs/rightContent/CreateSchemaDialog.js
index 31f7065f2c..2cfa8b3b47 100644
--- a/web-v2/web/src/app/catalogs/rightContent/CreateSchemaDialog.js
+++ b/web-v2/web/src/app/catalogs/rightContent/CreateSchemaDialog.js
@@ -32,7 +32,7 @@ import { nameRegex } from '@/lib/utils/regex'
 import { useResetFormOnCloseModal } from '@/lib/hooks/use-reset'
 import { genUpdates } from '@/lib/utils'
 import { cn } from '@/lib/utils/tailwind'
-import { useAppDispatch } from '@/lib/hooks/useStore'
+import { useAppDispatch, useAppSelector } from '@/lib/hooks/useStore'
 import { createSchema, updateSchema, getSchemaDetails } from 
'@/lib/store/metalakes'
 
 const { Paragraph } = Typography
@@ -71,6 +71,14 @@ export default function CreateSchemaDialog({ ...props }) {
   const dispatch = useAppDispatch()
 
   const paimonCatalogBackend = provider === 'lakehouse-paimon' && ['hive', 
'jdbc'].includes(catalogBackend)
+  const isIcebergJdbcCatalog = provider === 'lakehouse-iceberg' && 
catalogBackend === 'jdbc'
+
+  const auth = useAppSelector(state => state.auth)
+  const { systemConfig } = auth || {}
+  const separator = (systemConfig && 
systemConfig['gravitino.schema.separator']) || ':'
+  const escapeForRegex = s => s.replace(/[-\\/\^$*+?.()|[\]{}]/g, '\\$&')
+  const escSep = escapeForRegex(separator)
+  const dynamicSchemaNameRegex = new 
RegExp(`^\\w(?!.*${escSep}${escSep})(?!.*${escSep}$)[\\w\\/${escSep}=-]{0,63}$`)
 
   const [form] = Form.useForm()
   const values = Form.useWatch([], form)
@@ -226,8 +234,17 @@ export default function CreateSchemaDialog({ ...props }) {
                 <Form.Item
                   name='name'
                   label='Schema Name'
+                  extra={
+                    isIcebergJdbcCatalog
+                      ? `For nested schemas, separate each level with 
'${separator}' (for example: a${separator}b${separator}c).`
+                      : undefined
+                  }
                   data-refer='schema-name-field'
-                  rules={[{ required: true }, { type: 'string', max: 64 }, { 
pattern: new RegExp(nameRegex) }]}
+                  rules={[
+                    { required: true },
+                    { type: 'string', max: 64 },
+                    { pattern: isIcebergJdbcCatalog ? dynamicSchemaNameRegex : 
new RegExp(nameRegex) }
+                  ]}
                   messageVariables={{ label: 'schema name' }}
                 >
                   <Input placeholder={mismatchName} disabled={editSchema} />
diff --git 
a/web-v2/web/src/app/catalogs/rightContent/entitiesContent/CatalogDetailsPage.js
 
b/web-v2/web/src/app/catalogs/rightContent/entitiesContent/CatalogDetailsPage.js
index 4314c6e73b..afbd387513 100644
--- 
a/web-v2/web/src/app/catalogs/rightContent/entitiesContent/CatalogDetailsPage.js
+++ 
b/web-v2/web/src/app/catalogs/rightContent/entitiesContent/CatalogDetailsPage.js
@@ -55,6 +55,7 @@ import { useSearchParams } from 'next/navigation'
 import { deleteSchema, getCurrentEntityOwner } from '@/lib/store/metalakes'
 import Loading from '@/components/Loading'
 import CreateSchemaDialog from '../CreateSchemaDialog'
+import buildSchemaColumns from './SharedSchemaColumns'
 
 const CreateCatalogDialog = dynamic(() => import('../CreateCatalogDialog'), {
   loading: () => <Loading />,
@@ -245,113 +246,21 @@ export default function CatalogDetailsPage() {
     })
   }
 
-  const columns = useMemo(
-    () => [
-      {
-        title: 'Schema Name',
-        dataIndex: 'name',
-        key: 'name',
-        ellipsis: true,
-        sorter: (a, b) => 
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
-        width: 200,
-        render: name => (
-          <Link
-            data-refer={`schema-link-${name}`}
-            
href={`/catalogs?metalake=${encodeURIComponent(currentMetalake)}&catalogType=${catalogType}&catalog=${encodeURIComponent(catalog)}&schema=${encodeURIComponent(name)}`}
-          >
-            {name}
-          </Link>
-        )
-      },
-      {
-        title: 'Tags',
-        dataIndex: 'tags',
-        key: 'tags',
-        ellipsis: true,
-        render: (_, record) =>
-          record?.node === 'schema' ? (
-            <Tags
-              metadataObjectType={'schema'}
-              
metadataObjectFullName={`${record.namespace.at(-1)}.${record.name}`}
-              key={`schema-${record.namespace.at(-1)}.${record.name}-tags`}
-            />
-          ) : null
-      },
-      {
-        title: 'Policies',
-        dataIndex: 'policies',
-        key: 'policies',
-        ellipsis: true,
-        render: (_, record) =>
-          record?.node === 'schema' ? (
-            <Policies
-              metadataObjectType={'schema'}
-              
metadataObjectFullName={`${record.namespace.at(-1)}.${record.name}`}
-              key={`schema-${record.namespace.at(-1)}.${record.name}-policies`}
-            />
-          ) : null
-      },
-      ...(store.activatedDetails?.provider !== 'kafka'
-        ? [
-            {
-              title: 'Actions',
-              dataIndex: 'action',
-              key: 'action',
-              width: 100,
-              render: (_, record) => {
-                const NameContext = createContext(record.name)
+  const columns = useMemo(() => {
+    // wrap showDeleteConfirm to accept name only for shared builder
+    const showDeleteForShared = name => showDeleteConfirm(createContext(name), 
name, 'schema')
 
-                return (
-                  <div className='flex gap-2'>
-                    <NameContext.Provider 
value={record.name}>{contextHolder}</NameContext.Provider>
-                    <a>
-                      <Tooltip title='Edit'>
-                        <Icons.Pencil className='size-4' onClick={() => 
handleEditSchema(record.name)} />
-                      </Tooltip>
-                    </a>
-                    <a>
-                      <Tooltip title='Delete'>
-                        <Icons.Trash2Icon
-                          className='size-4'
-                          onClick={() => showDeleteConfirm(NameContext, 
record.name, 'schema')}
-                        />
-                      </Tooltip>
-                    </a>
-                    {anthEnable && (
-                      <Dropdown
-                        menu={{
-                          items: [
-                            {
-                              label: 'Set Owner',
-                              key: 'setOwner'
-                            }
-                          ],
-                          onClick: ({ key }) => {
-                            switch (key) {
-                              case 'setOwner':
-                                handleSetOwner('schema', 
`${catalog}.${record.name}`)
-                                break
-                            }
-                          }
-                        }}
-                        trigger={['hover']}
-                      >
-                        <Tooltip title='Settings'>
-                          <a onClick={e => e.preventDefault()}>
-                            <Icons.Settings className='size-4' />
-                          </a>
-                        </Tooltip>
-                      </Dropdown>
-                    )}
-                  </div>
-                )
-              }
-            }
-          ]
-        : [])
-    ],
-    [catalogType, anthEnable]
-  )
+    return buildSchemaColumns({
+      currentMetalake,
+      catalog,
+      catalogType,
+      anthEnable,
+      provider: store.activatedDetails?.provider,
+      handleEditSchema: name => handleEditSchema(name),
+      showDeleteConfirm: name => showDeleteForShared(name),
+      handleSetOwner
+    })
+  }, [currentMetalake, catalog, catalogType, anthEnable, 
store.activatedDetails?.provider])
 
   const { resizableColumns, components, tableWidth } = useAntdColumnResize(() 
=> {
     return { columns, minWidth: 100 }
@@ -359,6 +268,7 @@ export default function CatalogDetailsPage() {
 
   return (
     <div ref={ref}>
+      {contextHolder}
       <Spin spinning={store.activatedDetailsLoading}>
         <Flex className='mb-2' gap='small' align='flex-start'>
           <div className='size-8'>{renderIcon(store.activatedDetails)}</div>
diff --git 
a/web-v2/web/src/app/catalogs/rightContent/entitiesContent/SchemaDetailsPage.js 
b/web-v2/web/src/app/catalogs/rightContent/entitiesContent/SchemaDetailsPage.js
index cc73dc6bee..ab897be47c 100644
--- 
a/web-v2/web/src/app/catalogs/rightContent/entitiesContent/SchemaDetailsPage.js
+++ 
b/web-v2/web/src/app/catalogs/rightContent/entitiesContent/SchemaDetailsPage.js
@@ -49,8 +49,11 @@ import Icons from '@/components/Icons'
 import Policies from '@/components/PolicyTag'
 import TableActions from '@/components/TableActions'
 import PropertiesContent from '@/components/PropertiesContent'
+import buildSchemaColumns from './SharedSchemaColumns'
 import {
   getCatalogDetails,
+  fetchSchemas,
+  deleteSchema,
   deleteFileset,
   deleteModel,
   deleteTopic,
@@ -61,6 +64,7 @@ import {
 } from '@/lib/store/metalakes'
 import Link from 'next/link'
 import { cn } from '@/lib/utils/tailwind'
+import { to } from '@/lib/utils'
 import Loading from '@/components/Loading'
 import CreateSchemaDialog from '../CreateSchemaDialog'
 import CreateFilesetDialog from '../CreateFilesetDialog'
@@ -79,6 +83,8 @@ const { Search } = Input
 
 export default function SchemaDetailsPage() {
   const [openSchema, setOpenSchema] = useState(false)
+  const [editSchemaName, setEditSchemaName] = useState('')
+  const [editSchemaInit, setEditSchemaInit] = useState(true)
   const [openTable, setOpenTable] = useState(false)
   const [openFileset, setOpenFileset] = useState(false)
   const [openTopic, setOpenTopic] = useState(false)
@@ -105,10 +111,12 @@ export default function SchemaDetailsPage() {
   const [entityType, setEntityType] = useState('')
   const [tabKey, setTabKey] = useState('')
   const [nameCol, setNameCol] = useState('')
+  const [subSchemas, setSubSchemas] = useState([])
   const { ref, width } = useResizeObserver()
   const treeRef = useContext(TreeRefContext)
   const [catalogData, setCatalogData] = useState(null)
   const [ownerData, setOwnerData] = useState(null)
+  const [modal, contextHolder] = Modal.useModal()
 
   useEffect(() => {
     if (!currentMetalake || !catalog) return
@@ -135,9 +143,62 @@ export default function SchemaDetailsPage() {
   }, [anthEnable])
 
   useEffect(() => {
+    if (!currentMetalake || !catalog || !schema) {
+      setSubSchemas([])
+
+      return
+    }
+
+    const provider = catalogData?.provider
+    const catalogBackend = catalogData?.properties?.['catalog-backend']
+    const isIcebergJdbcCatalog = provider === 'lakehouse-iceberg' && 
catalogBackend === 'jdbc'
+
+    if (!isIcebergJdbcCatalog) {
+      // only iceberg jdbc catalogs support subschemas
+      setSubSchemas([])
+
+      return
+    }
+
+    const loadSubSchemas = async () => {
+      const [err, res] = await to(
+        dispatch(fetchSchemas({ metalake: currentMetalake, catalog, 
catalogType, parentSchema: schema }))
+      )
+      if (err || !res) {
+        setSubSchemas([])
+
+        return
+      }
+
+      const { schemas = [] } = res?.payload || {}
+
+      const nextSubSchemas = schemas.map(item => {
+        return {
+          ...item,
+          name: item.name,
+          key: item.name,
+          title: item.name
+        }
+      })
+
+      setSubSchemas(nextSubSchemas)
+    }
+
+    loadSubSchemas()
+  }, [currentMetalake, catalog, schema, catalogData?.provider, 
catalogData?.properties?.['catalog-backend']])
+
+  useEffect(() => {
+    const hasSubSchemas = subSchemas.length > 0
+    const withSubSchemas = tabs => (hasSubSchemas ? [{ label: 'Subschemas', 
key: 'Subschemas' }, ...tabs] : tabs)
+
+    let nextTabOptions = []
+    let nextCreateBtn = ''
+    let nextNameCol = ''
+    let nextEntityType = ''
+
     switch (catalogType) {
       case 'relational':
-        setTabOptions(
+        nextTabOptions = withSubSchemas(
           anthEnable
             ? [
                 { label: 'Tables', key: 'Tables' },
@@ -151,13 +212,12 @@ export default function SchemaDetailsPage() {
                 { label: 'Functions', key: 'Functions' }
               ]
         )
-        setTabKey('Tables')
-        setCreateBtn('Create Table')
-        setNameCol('Table Name')
-        setEntityType('table')
+        nextCreateBtn = 'Create Table'
+        nextNameCol = 'Table Name'
+        nextEntityType = 'table'
         break
       case 'messaging':
-        setTabOptions(
+        nextTabOptions = withSubSchemas(
           anthEnable
             ? [
                 { label: 'Topics', key: 'Topics' },
@@ -169,13 +229,12 @@ export default function SchemaDetailsPage() {
                 { label: 'Functions', key: 'Functions' }
               ]
         )
-        setTabKey('Topics')
-        setCreateBtn('Create Topic')
-        setNameCol('Topic Name')
-        setEntityType('topic')
+        nextCreateBtn = 'Create Topic'
+        nextNameCol = 'Topic Name'
+        nextEntityType = 'topic'
         break
       case 'fileset':
-        setTabOptions(
+        nextTabOptions = withSubSchemas(
           anthEnable
             ? [
                 { label: 'Filesets', key: 'Filesets' },
@@ -187,13 +246,12 @@ export default function SchemaDetailsPage() {
                 { label: 'Functions', key: 'Functions' }
               ]
         )
-        setTabKey('Filesets')
-        setCreateBtn('Create Fileset')
-        setNameCol('Fileset Name')
-        setEntityType('fileset')
+        nextCreateBtn = 'Create Fileset'
+        nextNameCol = 'Fileset Name'
+        nextEntityType = 'fileset'
         break
       case 'model':
-        setTabOptions(
+        nextTabOptions = withSubSchemas(
           anthEnable
             ? [
                 { label: 'Models', key: 'Models' },
@@ -205,22 +263,73 @@ export default function SchemaDetailsPage() {
                 { label: 'Functions', key: 'Functions' }
               ]
         )
-        setTabKey('Models')
-        setCreateBtn('Register Model')
-        setNameCol('Model Name')
-        setEntityType('model')
+        nextCreateBtn = 'Register Model'
+        nextNameCol = 'Model Name'
+        nextEntityType = 'model'
         break
       default:
-        setTabOptions([])
-        setCreateBtn('')
-        setEntityType('')
+        nextTabOptions = []
+        nextCreateBtn = ''
+        nextEntityType = ''
+    }
+
+    setTabOptions(nextTabOptions)
+    setCreateBtn(nextCreateBtn)
+    setNameCol(nextNameCol)
+    setEntityType(nextEntityType)
+  }, [catalogType, anthEnable, subSchemas])
+
+  useEffect(() => {
+    const hasSubSchemas = subSchemas.length > 0
+    if (hasSubSchemas) {
+      setTabKey('Subschemas')
+    } else {
+      switch (catalogType) {
+        case 'relational':
+          setTabKey('Tables')
+          break
+        case 'messaging':
+          setTabKey('Topics')
+          break
+        case 'fileset':
+          setTabKey('Filesets')
+          break
+        case 'model':
+          setTabKey('Models')
+          break
+        default:
+          setTabKey('')
+      }
     }
-  }, [catalogType, anthEnable])
+  }, [subSchemas, catalogType])
 
   const onChangeTab = key => {
     setTabKey(key)
   }
 
+  const showDeleteSchemaConfirm = name => {
+    modal.confirm({
+      title: `Are you sure to delete the schema ${name}?`,
+      icon: <ExclamationCircleFilled />,
+      okText: 'Delete',
+      okType: 'danger',
+      cancelText: 'Cancel',
+      onOk: async () => {
+        await dispatch(deleteSchema({ metalake: currentMetalake, catalog, 
catalogType, schema: name }))
+
+        const [err, res] = await to(
+          dispatch(fetchSchemas({ metalake: currentMetalake, catalog, 
catalogType, parentSchema: schema }))
+        )
+        if (!err && res) {
+          const { schemas = [] } = res.payload || {}
+          const nextSubSchemas = schemas.map(item => ({ ...item, name: 
item.name, key: item.name, title: item.name }))
+          setSubSchemas(nextSubSchemas)
+        }
+        treeRef.current.onLoadData({ key: catalog, nodeType: 'catalog', inUse: 
'true' }, true)
+      }
+    })
+  }
+
   const tableData = [...store.tableData]
     .filter(c => {
       if (search === '') return true
@@ -247,6 +356,18 @@ export default function SchemaDetailsPage() {
       children: undefined
     }))
 
+  const subSchemaData = [...subSchemas]
+    .filter(item => {
+      if (search === '') return true
+
+      return item.name.includes(search)
+    })
+    .map(item => ({
+      ...item,
+      key: item.name,
+      children: undefined
+    }))
+
   const tagContent = (
     <div>
       <Tags readOnly={true} metadataObjectType={'schema'} 
metadataObjectFullName={`${catalog}.${schema}`} />
@@ -290,6 +411,8 @@ export default function SchemaDetailsPage() {
   }
 
   const handleEditSchema = () => {
+    setEditSchemaName(schema)
+    setEditSchemaInit(true)
     setOpenSchema(true)
   }
 
@@ -334,7 +457,20 @@ export default function SchemaDetailsPage() {
     setOpenOwner(true)
   }
 
-  const showDeleteConfirm = async (modal, entityObj, type) => {
+  const showDeleteConfirm = async (maybeModalOrEntityObj, maybeEntityObj, 
maybeType) => {
+    // support two call signatures:
+    // 1) showDeleteConfirm(modalInstance, entityObj, type)
+    // 2) showDeleteConfirm(entityObj, type)
+    let modalToUse = modal
+    let entityObj = maybeModalOrEntityObj
+    let type = maybeEntityObj
+
+    if (maybeModalOrEntityObj && typeof maybeModalOrEntityObj.confirm === 
'function') {
+      modalToUse = maybeModalOrEntityObj
+      entityObj = maybeEntityObj
+      type = maybeType
+    }
+
     const { name: entity, storageLocation, type: managedOrExtenalType } = 
entityObj
     let isManaged = false
     let location = ''
@@ -360,7 +496,7 @@ export default function SchemaDetailsPage() {
       validateFn = fn
     }
 
-    modal.confirm({
+    modalToUse.confirm({
       title: `Are you sure to delete the ${type} ${entity}?`,
       icon: <ExclamationCircleFilled />,
       content: (
@@ -500,7 +636,7 @@ export default function SchemaDetailsPage() {
         render: (_, record) => (
           <a data-refer={`delete-view-${record.name}`}>
             <Tooltip title='Delete'>
-              <Icons.Trash2Icon className='size-4' onClick={() => 
showDeleteConfirm(Modal, record, 'view')} />
+              <Icons.Trash2Icon className='size-4' onClick={() => 
showDeleteConfirm(record, 'view')} />
             </Tooltip>
           </a>
         )
@@ -509,6 +645,23 @@ export default function SchemaDetailsPage() {
     [currentMetalake, catalogType, catalog, schema, catalogData?.provider]
   )
 
+  const subSchemaColumns = useMemo(() => {
+    return buildSchemaColumns({
+      currentMetalake,
+      catalog,
+      catalogType,
+      anthEnable,
+      provider: catalogData?.provider,
+      handleEditSchema: name => {
+        setEditSchemaName(name)
+        setEditSchemaInit(false)
+        setOpenSchema(true)
+      },
+      showDeleteConfirm: name => showDeleteSchemaConfirm(name),
+      handleSetOwner
+    })
+  }, [currentMetalake, catalog, catalogType, anthEnable, 
catalogData?.provider])
+
   const { resizableColumns, components, tableWidth } = useAntdColumnResize(() 
=> {
     return { columns, minWidth: 100 }
   }, [columns])
@@ -521,8 +674,17 @@ export default function SchemaDetailsPage() {
     return { columns: viewColumns, minWidth: 100 }
   }, [viewColumns])
 
+  const {
+    resizableColumns: subSchemaResizableColumns,
+    components: subSchemaComponents,
+    tableWidth: subSchemaTableWidth
+  } = useAntdColumnResize(() => {
+    return { columns: subSchemaColumns, minWidth: 100 }
+  }, [subSchemaColumns])
+
   return (
     <div ref={ref}>
+      {contextHolder}
       <Spin spinning={store.activatedDetailsLoading}>
         <Flex className='mb-2' gap='small' align='flex-start'>
           <div className='size-8'>
@@ -611,13 +773,31 @@ export default function SchemaDetailsPage() {
           )}
         </Space>
       </Spin>
-      <Tabs data-refer='details-tabs' defaultActiveKey={tabKey} 
onChange={onChangeTab} items={tabOptions} />
+      <Tabs data-refer='details-tabs' activeKey={tabKey} 
onChange={onChangeTab} items={tabOptions} />
       {tabKey === 'Associated Roles' ? (
         <AssociatedTable
           metalake={currentMetalake}
           metadataObjectType={'schema'}
           metadataObjectFullName={`${catalog}.${schema}`}
         />
+      ) : tabKey === 'Subschemas' ? (
+        <>
+          <Flex justify='flex-end' className='mb-4'>
+            <div className='flex w-1/3 gap-4'>
+              <Search name='searchSubSchemaInput' placeholder='Search...' 
value={search} onChange={onSearchTable} />
+            </div>
+          </Flex>
+          <Table
+            data-refer='subschema-list-grid'
+            size='small'
+            style={{ maxHeight: 'calc(100vh - 30rem)' }}
+            scroll={{ x: subSchemaTableWidth, y: 'calc(100vh - 37rem)' }}
+            dataSource={subSchemaData}
+            pagination={{ position: ['bottomCenter'], showSizeChanger: true }}
+            columns={subSchemaResizableColumns}
+            components={subSchemaComponents}
+          />
+        </>
       ) : tabKey === 'Functions' ? (
         <Functions metalake={currentMetalake} catalog={catalog} 
schema={schema} />
       ) : tabKey === 'Views' ? (
@@ -738,8 +918,8 @@ export default function SchemaDetailsPage() {
           catalogType={catalogType}
           provider={catalogData?.provider}
           
locationProviders={catalogData?.properties?.['filesystem-providers']?.split(',')
 || []}
-          editSchema={schema}
-          init={true}
+          editSchema={editSchemaName || schema}
+          init={editSchemaInit}
         />
       )}
       {openOwner && (
diff --git 
a/web-v2/web/src/app/catalogs/rightContent/entitiesContent/SharedSchemaColumns.js
 
b/web-v2/web/src/app/catalogs/rightContent/entitiesContent/SharedSchemaColumns.js
new file mode 100644
index 0000000000..f8e29cfe11
--- /dev/null
+++ 
b/web-v2/web/src/app/catalogs/rightContent/entitiesContent/SharedSchemaColumns.js
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+'use client'
+
+import React from 'react'
+import Link from 'next/link'
+import { Tooltip, Dropdown } from 'antd'
+import Tags from '@/components/CustomTags'
+import Policies from '@/components/PolicyTag'
+import Icons from '@/components/Icons'
+
+export function buildSchemaColumns({
+  currentMetalake,
+  catalog,
+  catalogType,
+  anthEnable,
+  provider,
+  handleEditSchema,
+  showDeleteConfirm,
+  handleSetOwner
+}) {
+  return [
+    {
+      title: 'Schema Name',
+      dataIndex: 'name',
+      key: 'name',
+      ellipsis: true,
+      sorter: (a, b) => 
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
+      width: 200,
+      render: name => (
+        <Link
+          data-refer={`schema-link-${name}`}
+          
href={`/catalogs?metalake=${encodeURIComponent(currentMetalake)}&catalogType=${catalogType}&catalog=${encodeURIComponent(catalog)}&schema=${encodeURIComponent(name)}`}
+        >
+          {name}
+        </Link>
+      )
+    },
+    {
+      title: 'Tags',
+      dataIndex: 'tags',
+      key: 'tags',
+      ellipsis: true,
+      render: (_, record) =>
+        record?.node === 'schema' ? (
+          <Tags metadataObjectType={'schema'} 
metadataObjectFullName={`${record.namespace?.at(-1)}.${record.name}`} />
+        ) : null
+    },
+    {
+      title: 'Policies',
+      dataIndex: 'policies',
+      key: 'policies',
+      ellipsis: true,
+      render: (_, record) =>
+        record?.node === 'schema' ? (
+          <Policies
+            metadataObjectType={'schema'}
+            
metadataObjectFullName={`${record.namespace?.at(-1)}.${record.name}`}
+          />
+        ) : null
+    },
+    ...(provider !== 'kafka'
+      ? [
+          {
+            title: 'Actions',
+            dataIndex: 'action',
+            key: 'action',
+            width: 120,
+            render: (_, record) => {
+              return (
+                <div className='flex gap-2'>
+                  <a>
+                    <Tooltip title='Edit'>
+                      <Icons.Pencil className='size-4' onClick={() => 
handleEditSchema(record.name)} />
+                    </Tooltip>
+                  </a>
+                  <a>
+                    <Tooltip title='Delete'>
+                      <Icons.Trash2Icon className='size-4' onClick={() => 
showDeleteConfirm(record.name)} />
+                    </Tooltip>
+                  </a>
+                  {anthEnable && (
+                    <Dropdown
+                      menu={{
+                        items: [
+                          {
+                            label: 'Set Owner',
+                            key: 'setOwner'
+                          }
+                        ],
+                        onClick: ({ key }) => {
+                          switch (key) {
+                            case 'setOwner':
+                              handleSetOwner('schema', 
`${catalog}.${record.name}`)
+                              break
+                          }
+                        }
+                      }}
+                      trigger={['hover']}
+                    >
+                      <Tooltip title='Settings'>
+                        <a onClick={e => e.preventDefault()}>
+                          <Icons.Settings className='size-4' />
+                        </a>
+                      </Tooltip>
+                    </Dropdown>
+                  )}
+                </div>
+              )
+            }
+          }
+        ]
+      : [])
+  ]
+}
+
+export default buildSchemaColumns
diff --git a/web-v2/web/src/lib/api/schemas/index.js 
b/web-v2/web/src/lib/api/schemas/index.js
index 0848c1e545..79a5f6016c 100644
--- a/web-v2/web/src/lib/api/schemas/index.js
+++ b/web-v2/web/src/lib/api/schemas/index.js
@@ -34,9 +34,10 @@ const Apis = {
     
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(catalog)}/schemas/${encodeURIComponent(schema)}`
 }
 
-export const getSchemasApi = params => {
+export const getSchemasApi = ({ metalake, catalog, parentSchema }) => {
   return defHttp.get({
-    url: `${Apis.GET(params)}`
+    url: `${Apis.GET({ metalake, catalog })}`,
+    params: parentSchema ? { parentSchema } : undefined
   })
 }
 
diff --git a/web-v2/web/src/lib/store/metalakes/index.js 
b/web-v2/web/src/lib/store/metalakes/index.js
index 0654be50a7..59cc872fd7 100644
--- a/web-v2/web/src/lib/store/metalakes/index.js
+++ b/web-v2/web/src/lib/store/metalakes/index.js
@@ -92,18 +92,20 @@ const remapExpandedAndLoadedNodes = ({ getState, mapNode }) 
=> {
 
 const mergeWithFunctionNodes = ({ tree, key, entities }) => {
   const existingNode = findInTree(tree, 'key', key)
+  const subSchemas = existingNode?.children?.filter(child => child?.node === 
'schema') || []
   const functions = existingNode?.children?.filter(child => child?.node === 
'function') || []
   const views = existingNode?.children?.filter(child => child?.node === 
'view') || []
 
-  return _.uniqBy([...entities, ...functions, ...views], 'key')
+  return _.uniqBy([...subSchemas, ...entities, ...functions, ...views], 'key')
 }
 
 const mergeWithViewNodes = ({ tree, key, entities }) => {
   const existingNode = findInTree(tree, 'key', key)
+  const subSchemas = existingNode?.children?.filter(child => child?.node === 
'schema') || []
   const tables = existingNode?.children?.filter(child => child?.node === 
'table') || []
   const functions = existingNode?.children?.filter(child => child?.node === 
'function') || []
 
-  return _.uniqBy([...tables, ...functions, ...entities], 'key')
+  return _.uniqBy([...subSchemas, ...tables, ...functions, ...entities], 'key')
 }
 
 export const fetchMetalakes = createAsyncThunk('appMetalakes/fetchMetalakes', 
async (params, { getState }) => {
@@ -276,6 +278,19 @@ export const setIntoTreeNodeWithFetch = createAsyncThunk(
         dispatch(setLoadedNodes(loaded))
       }
     } else if (pathArr.length === 4) {
+      // Only fetch subschemas for iceberg catalog with jdbc backend
+      const catalogNode = findInTree(
+        getState().metalakes.metalakeTree,
+        'key',
+        `{{${metalake}}}{{${catalog}}}{{${type}}}`
+      )
+      const provider = catalogNode?.provider || catalogNode?.catalogType || 
null
+      const catalogBackend = catalogNode?.properties?.['catalog-backend']
+      const isIcebergJdbcCatalog = provider === 'lakehouse-iceberg' && 
catalogBackend === 'jdbc'
+
+      const childSchemasPromise = isIcebergJdbcCatalog
+        ? getSchemasApi({ metalake, catalog, parentSchema: schema })
+        : Promise.resolve(null)
       let entityPromise = Promise.resolve(null)
       switch (type) {
         case 'relational':
@@ -299,12 +314,32 @@ export const setIntoTreeNodeWithFetch = createAsyncThunk(
           ? getViewsApi({ metalake, catalog, schema }, { errorMessageMode: 
'none' })
           : Promise.resolve(null)
 
-      const [funcResult, entityResult, viewResult] = await Promise.allSettled([
+      const [funcResult, entityResult, viewResult, childSchemasResult] = await 
Promise.allSettled([
         getFunctionsApi({ metalake, catalog, schema, details: false }),
         entityPromise,
-        viewsPromise
+        viewsPromise,
+        childSchemasPromise
       ])
 
+      const childSchemas =
+        childSchemasResult.status === 'fulfilled' && childSchemasResult.value
+          ? (childSchemasResult.value?.identifiers || []).map(schemaItem => {
+              const schemaName = schemaItem.name
+
+              return {
+                ...schemaItem,
+                node: 'schema',
+                id: 
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schemaName}}}`,
+                key: 
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schemaName}}}`,
+                path: `?${new URLSearchParams({ metalake, catalog, 
catalogType: type, schema: schemaName }).toString()}`,
+                name: schemaName,
+                title: schemaName,
+                tables: [],
+                children: []
+              }
+            })
+          : []
+
       const functions =
         funcResult.status === 'fulfilled'
           ? (funcResult.value?.identifiers || []).map(functionItem => {
@@ -415,7 +450,7 @@ export const setIntoTreeNodeWithFetch = createAsyncThunk(
         }
       }
 
-      result.data = [...entities, ...functions, ...views]
+      result.data = [...childSchemas, ...entities, ...functions, ...views]
     }
 
     return result
@@ -830,12 +865,12 @@ export const switchInUseCatalog = createAsyncThunk(
 
 export const fetchSchemas = createAsyncThunk(
   'appMetalakes/fetchSchemas',
-  async ({ init, page, metalake, catalog, catalogType }, { getState, dispatch 
}) => {
+  async ({ init, page, metalake, catalog, catalogType, parentSchema }, { 
getState, dispatch }) => {
     if (init) {
       await dispatch(resetTableData())
       await dispatch(setTableLoading(true))
     }
-    const [err, res] = await to(getSchemasApi({ metalake, catalog }))
+    const [err, res] = await to(getSchemasApi({ metalake, catalog, 
parentSchema }))
     await dispatch(setTableLoading(false))
 
     if (err || !res) {
@@ -847,11 +882,17 @@ export const fetchSchemas = createAsyncThunk(
 
       if (isCanceledRequest) {
         const catalogKey = `{{${metalake}}}{{${catalog}}}{{${catalogType}}}`
-        const catalogNode = findInTree(getState().metalakes.metalakeTree, 
'key', catalogKey)
-        const cachedSchemas = (catalogNode?.children || []).filter(item => 
item?.node === 'schema')
+
+        const parentKey = parentSchema
+          ? 
`{{${metalake}}}{{${catalog}}}{{${catalogType}}}{{${parentSchema}}}`
+          : catalogKey
+        const parentNode = findInTree(getState().metalakes.metalakeTree, 
'key', parentKey)
+        const cachedSchemas = (parentNode?.children || []).filter(item => 
item?.node === 'schema')
 
         if (cachedSchemas.length > 0) {
-          dispatch(setExpandedNodes([`{{${metalake}}}`, catalogKey]))
+          const expanded =
+            parentKey === catalogKey ? [`{{${metalake}}}`, catalogKey] : 
[`{{${metalake}}}`, catalogKey, parentKey]
+          dispatch(setExpandedNodes(expanded))
 
           return { schemas: cachedSchemas, page, init }
         }
@@ -863,39 +904,43 @@ export const fetchSchemas = createAsyncThunk(
     const { identifiers = [] } = res || {}
 
     const schemas = identifiers.map(schema => {
+      const schemaName = schema.name
+
       const schemaItem = findInTree(
         getState().metalakes.metalakeTree,
         'key',
-        `{{${metalake}}}{{${catalog}}}{{${catalogType}}}{{${schema.name}}}`
+        `{{${metalake}}}{{${catalog}}}{{${catalogType}}}{{${schemaName}}}`
       )
 
       return {
         ...schema,
+        name: schemaName,
         node: 'schema',
-        id: 
`{{${metalake}}}{{${catalog}}}{{${catalogType}}}{{${schema.name}}}`,
-        key: 
`{{${metalake}}}{{${catalog}}}{{${catalogType}}}{{${schema.name}}}`,
-        path: `?${new URLSearchParams({ metalake, catalog, catalogType, 
schema: schema.name }).toString()}`,
-        name: schema.name,
-        title: schema.name,
+        id: `{{${metalake}}}{{${catalog}}}{{${catalogType}}}{{${schemaName}}}`,
+        key: 
`{{${metalake}}}{{${catalog}}}{{${catalogType}}}{{${schemaName}}}`,
+        path: `?${new URLSearchParams({ metalake, catalog, catalogType, 
schema: schemaName }).toString()}`,
+        title: schemaName,
         tables: schemaItem ? schemaItem.children : [],
         children: schemaItem ? schemaItem.children : []
       }
     })
 
     if (init) {
-      const catalogKey = `{{${metalake}}}{{${catalog}}}{{${catalogType}}}`
+      const parentKey = parentSchema
+        ? `{{${metalake}}}{{${catalog}}}{{${catalogType}}}{{${parentSchema}}}`
+        : `{{${metalake}}}{{${catalog}}}{{${catalogType}}}`
 
       // Always update tree nodes when init is true
       dispatch(
         setIntoTreeNodes({
-          key: catalogKey,
+          key: parentKey,
           data: schemas
         })
       )
 
-      // Add catalog to loadedNodes if not already present
-      if (!getState().metalakes.loadedNodes.includes(catalogKey)) {
-        dispatch(setLoadedNodes([...getState().metalakes.loadedNodes, 
catalogKey]))
+      // Add parent node to loadedNodes if not already present
+      if (!getState().metalakes.loadedNodes.includes(parentKey)) {
+        dispatch(setLoadedNodes([...getState().metalakes.loadedNodes, 
parentKey]))
       }
     }
 


Reply via email to