jerryshao commented on code in PR #12900:
URL: https://github.com/apache/gravitino/pull/12900#discussion_r4280173358


##########
web-v2/web/src/app/catalogs/rightContent/entitiesContent/SemanticModelDetailsPage.js:
##########
@@ -0,0 +1,812 @@
+/*
+ * 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 { useEffect, useMemo, useRef, useState } from 'react'
+import {
+  Button,
+  Divider,
+  Flex,
+  Input,
+  Modal,
+  Popover,
+  Space,
+  Spin,
+  Table,
+  Tabs,
+  Tag,
+  Tooltip,
+  Typography,
+  message
+} from 'antd'
+import styles from './SemanticModelDetailsPage.module.css'
+import { useAntdColumnResize } from 'react-antd-column-resize'
+import { useRouter, useSearchParams } from 'next/navigation'
+import Tags from '@/components/CustomTags'
+import GetOwner from '@/components/GetOwner'
+import Icons from '@/components/Icons'
+import PropertiesContent from '@/components/PropertiesContent'
+import { useAppDispatch, useAppSelector } from '@/lib/hooks/useStore'
+import { getSemanticModelDetails } from '@/lib/store/metalakes'
+import { copyToClipboard } from '@/lib/utils'
+import { formatToDateTime, isValidDate } from '@/lib/utils/date'
+import { formatSemanticModelDefinition } from '@/lib/utils/semanticModel'
+import { ColumnTypeColorEnum } from '@/config'
+import SemanticModelExpression from './SemanticModelExpression'
+import SemanticModelAIContext from './SemanticModelAIContext'
+import SemanticModelExport from './SemanticModelExport'
+import SemanticModelExtensions from './SemanticModelExtensions'
+import CreateSemanticModelDialog from '../CreateSemanticModelDialog'
+import SemanticMetadataEditor from '../SemanticMetadataEditor'
+
+const { Title, Paragraph } = Typography
+
+const sourceFullName = source => {
+  if (!source) {
+    return '-'
+  }
+
+  return [...(source.namespace || []), source.name].filter(Boolean).join('.')
+}
+
+const renderDataType = datatype => {
+  if (!datatype) return <span>-</span>
+  const type = { DateTime: 'timestamp', DateTimeTz: 'timestamp_tz' }[datatype] 
|| datatype.toLowerCase()
+
+  return <Tag color={ColumnTypeColorEnum[type] || undefined}>{datatype}</Tag>
+}
+
+const topAlignedColumns = columns =>
+  columns.map(column => ({ ...column, onCell: () => ({ style: { verticalAlign: 
'top' } }) }))
+
+const renderColumns = columns => <span className='font-mono text-xs'>{(columns 
|| []).join(', ') || '-'}</span>
+
+const hasMetadata = entity => !!entity.aiContext || 
entity.customExtensions?.length > 0
+
+export default function SemanticModelDetailsPage() {
+  const searchParams = useSearchParams()
+  const router = useRouter()
+  const dispatch = useAppDispatch()
+  const metalake = searchParams.get('metalake')
+  const catalog = searchParams.get('catalog')
+  const schema = searchParams.get('schema')
+  const semanticModelName = searchParams.get('semanticModel')
+  const auth = useAppSelector(state => state.auth)
+  const { anthEnable } = auth
+  const store = useAppSelector(state => state.metalakes)
+  const semanticModel = store.activatedDetails?.name === semanticModelName ? 
store.activatedDetails : null

Review Comment:
   [Question] Is name equality meant to be the identity check here? A table and 
a Semantic Model may share a name in one schema - that is exactly why 
`semanticModelNodeKey` was given an entity-type segment - so an 
`activatedDetails` holding table `orders` satisfies this test while the URL 
points at model `orders`.
   
   The visible effect is a short render of the wrong entity: the Overview 
header shows the table's comment, creator and audit times as the model's, while 
`definition` is `undefined` so the tabs are empty. It is reachable today in the 
window in `page.js` (lines ~360-366) where the Semantic Model list is fetched 
before `resetActivatedDetails()` runs, and it would survive a fix that guards 
the other writers only by request id.
   
   If you agree, carrying the entity kind on `activatedDetails` (or storing the 
model under its own slice key rather than the shared one) would make this an 
identity check rather than a name check.
   
   Verified by: reading page.js:358-380 (reset happens after the awaited list 
fetch) and semanticModel.js:378 (typed node key), with the detail reducers at 
index.js:3085/3121/3302 writing the shared `activatedDetails`.



##########
web-v2/web/src/lib/store/metalakes/index.js:
##########
@@ -3086,6 +3314,48 @@ export const appMetalakesSlice = createSlice({
         toast.error(action.error.message)
       }
     })
+    builder.addCase(fetchSemanticModels.pending, (state, action) => {
+      const key = semanticModelSchemaKey(action.meta.arg)
+      state.semanticModelRequests[key] = { requestId: action.meta.requestId }

Review Comment:
   [Nit] `semanticModelRequests` is keyed by schema and never pruned: every 
schema visited in a session leaves an entry behind, and 
`fetchSemanticModels.fulfilled` caches that schema's whole model list on it 
(line 3326). Nothing removes entries - only `resetTree`/`resetMetalakeStore` 
clear the map - so browsing a metalake with many relational schemas accumulates 
full lists in the store for the life of the page.
   
   Only two consumers need the entry: the staleness check in the thunk and 
`setIntoTreeNodeWithFetch.fulfilled`. Deleting the entry once no request is in 
flight for that key (or keeping only the requestId and reading current nodes 
from the tree, which the fulfilled handler already falls back to) would bound 
it.
   
   Verified by: tracing every write and read of `semanticModelRequests` in this 
file (lines 242, 2389, 3319, 3324-3326, 2617, 2909) - no delete path.



##########
web-v2/web/src/app/catalogs/rightContent/SemanticMetadataEditor.js:
##########
@@ -0,0 +1,372 @@
+/*
+ * 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.
+ */
+
+import { useEffect, useRef, useState } from 'react'
+import { Alert, Button, Form, Input, Modal, Select, Space, Tabs, theme } from 
'antd'
+import { isEqual } from 'lodash-es'
+import { useAppDispatch } from '@/lib/hooks/useStore'
+import { updateSemanticModel } from '@/lib/store/metalakes'
+import { semanticDataTypes, validateSemanticModelDefinition } from 
'@/lib/utils/semanticModel'
+import {
+  getSemanticMetadataEntity,
+  semanticMetadataFormValues,
+  updateSemanticMetadata
+} from '@/lib/utils/semanticMetadata'
+
+export default function SemanticMetadataEditor({
+  definition,
+  target,
+  metalake,
+  catalog,
+  schema,
+  semanticModel,
+  onClose,
+  onSaved
+}) {
+  const { token } = theme.useToken()
+  const dispatch = useAppDispatch()
+  const [form] = Form.useForm()
+  const [saving, setSaving] = useState(false)
+  const [errors, setErrors] = useState([])
+  const [section, setSection] = useState(target.kind === 'model' ? 'context' : 
target.section || 'basic')
+  const [focusField, setFocusField] = useState(null)
+  const [confirmDiscard, setConfirmDiscard] = useState(false)
+  const errorRef = useRef(null)
+  const expressionRef = useRef(null)
+
+  useEffect(() => {
+    if (!focusField) return
+
+    // Tabs reveal their panel after the active key changes; focus once it is 
visible.
+    const frame = requestAnimationFrame(() => {
+      form.scrollToField(focusField, { block: 'nearest' })
+      const input = form.getFieldInstance(focusField)
+      if (input?.focus) input.focus()
+      else if (focusField[0] === 'dialects') expressionRef.current?.focus()
+      setFocusField(null)
+    })
+
+    return () => cancelAnimationFrame(frame)
+  }, [section, focusField, form])
+
+  useEffect(() => {
+    if (errors.length) errorRef.current?.focus()
+  }, [errors])
+
+  // Keep the editing snapshot stable through list/detail refreshes while the 
dialog is open.
+  const [original] = useState(definition)
+  const entity = getSemanticMetadataEntity(original, target)
+  const [initial] = useState(() => semanticMetadataFormValues(entity))
+  const structuredContextInitialized = useRef(initial.contextFormat === 
'structured')
+  const contextFormat = Form.useWatch('contextFormat', form) || 
initial.contextFormat
+  const hasExpression = ['field', 'metric'].includes(target.kind)
+  const title = target.kind === 'model' ? 'Edit Model AI Context' : `Edit 
${target.kind} ${target.name}`
+
+  const close = () => {
+    if (saving) return
+    if (isEqual(initial, form.getFieldsValue(true))) onClose()
+    else setConfirmDiscard(true)
+  }
+
+  const save = async () => {
+    try {
+      await form.validateFields()
+    } catch (failure) {
+      const name = failure.errorFields?.[0]?.name
+      if (name) {
+        setSection(
+          name[0] === 'dialects'
+            ? 'expressions'
+            : ['contextFormat', 'contextText', 'instructions', 'synonyms', 
'examples'].includes(name[0])
+              ? 'context'
+              : 'basic'
+        )
+        setFocusField(name)
+      }
+
+      return
+    }
+    const updated = updateSemanticMetadata(original, target, 
form.getFieldsValue(true))
+    const problems = validateSemanticModelDefinition(updated)
+    setErrors(problems)
+    if (problems.length) return
+    if (isEqual(original, updated)) {
+      onClose()
+
+      return
+    }
+    setSaving(true)
+    try {
+      const result = await dispatch(
+        updateSemanticModel({
+          metalake,
+          catalog,
+          schema,
+          semanticModel,
+          data: { updates: [{ '@type': 'replaceDefinition', definition: 
updated }] }
+        })
+      )
+      if (result.error || result.payload?.err || !result.payload) {
+        setErrors([result.payload?.message || result.error?.message || 'Unable 
to save metadata.'])
+
+        return
+      }
+      await onSaved(result.payload)
+      onClose()
+    } finally {
+      setSaving(false)
+    }
+  }
+
+  const basic = (
+    <>
+      {target.kind === 'field' && (
+        <Form.Item name='label' label='Label'>
+          <Input />
+        </Form.Item>
+      )}
+      {['dataset', 'field', 'metric'].includes(target.kind) && (
+        <Form.Item name='description' label='Description'>
+          <Input.TextArea autoSize={{ minRows: 2, maxRows: 6 }} />
+        </Form.Item>
+      )}
+      {hasExpression && (
+        <>
+          <Form.Item name='datatype' label='Data type'>
+            <Select allowClear placeholder='Not specified' 
options={semanticDataTypes.map(value => ({ value }))} />
+          </Form.Item>
+        </>
+      )}
+      {target.kind === 'relationship' && (
+        <>
+          <Space align='start' className='w-full'>
+            {['from', 'to'].map(side => (
+              <Form.Item
+                key={side}
+                name={side}
+                label={side === 'from' ? 'From dataset' : 'To dataset'}
+                rules={[{ required: true }]}
+              >
+                <Select
+                  className='min-w-48'
+                  options={original.datasets.map(dataset => ({ value: 
dataset.name, label: dataset.name }))}
+                />
+              </Form.Item>
+            ))}
+          </Space>
+          {['fromColumns', 'toColumns'].map(side => (
+            <Form.Item
+              key={side}
+              name={side}
+              label={side === 'fromColumns' ? 'From columns' : 'To columns'}
+              dependencies={[side === 'fromColumns' ? 'toColumns' : 
'fromColumns']}
+              rules={[
+                {
+                  validator: (_, values) => {
+                    if (!values?.length || values.some(value => !value.trim()))
+                      return Promise.reject(new Error('Select at least one 
non-empty column.'))
+                    const other = form.getFieldValue(side === 'fromColumns' ? 
'toColumns' : 'fromColumns')
+                    if (values.length !== other?.length)
+                      return Promise.reject(new Error('From and to columns 
must have matching counts.'))
+
+                    return Promise.resolve()
+                  }
+                }
+              ]}
+            >
+              <Select mode='tags' />
+            </Form.Item>
+          ))}
+        </>
+      )}
+    </>
+  )
+
+  const expressions = (
+    <div ref={expressionRef} tabIndex={-1}>
+      <Form.List
+        name='dialects'
+        rules={[
+          {
+            validator: (_, values) =>
+              values?.length ? Promise.resolve() : Promise.reject(new 
Error('Add at least one dialect expression.'))
+          }
+        ]}
+      >
+        {(fields, { add, remove }, { errors: listErrors }) => (
+          <>
+            {fields.map(({ key, name, ...rest }) => (
+              <div key={key} className='mb-3 rounded border border-borderColor 
p-3'>
+                <Form.Item
+                  {...rest}
+                  name={[name, 'dialect']}
+                  label='Dialect'
+                  rules={[
+                    { required: true, whitespace: true },
+                    {
+                      validator: (_, value) =>
+                        (form.getFieldValue('dialects') || []).filter(item => 
item?.dialect === value).length > 1

Review Comment:
   [Nit] The duplicate-dialect validator reads sibling rows but nothing 
revalidates them, so the error is sticky on the row the user did not touch. 
With dialects `trino` and `trino`, both rows show "Dialect names must be 
unique."; renaming the second to `spark` revalidates only that field, and the 
first row keeps the error until Save re-runs `validateFields()` on everything.
   
   Adding `dependencies={['dialects']}` is not enough inside a `Form.List`; the 
usual fix is to call `form.validateFields([['dialects', i, 'dialect']])` for 
the other rows from this field's `onChange`, or validate uniqueness once on the 
list itself (the `Form.List` `rules` at line 205 already render through 
`Form.ErrorList`).
   
   Verified by: reading the validator at lines 222-227 and the list rules at 
203-211; Ant Design revalidates only the changed path unless dependencies or an 
explicit `validateFields` call name the others.



##########
web-v2/web/src/app/catalogs/rightContent/entitiesContent/SemanticModelDetailsPage.js:
##########
@@ -0,0 +1,812 @@
+/*
+ * 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 { useEffect, useMemo, useRef, useState } from 'react'
+import {
+  Button,
+  Divider,
+  Flex,
+  Input,
+  Modal,
+  Popover,
+  Space,
+  Spin,
+  Table,
+  Tabs,
+  Tag,
+  Tooltip,
+  Typography,
+  message
+} from 'antd'
+import styles from './SemanticModelDetailsPage.module.css'
+import { useAntdColumnResize } from 'react-antd-column-resize'
+import { useRouter, useSearchParams } from 'next/navigation'
+import Tags from '@/components/CustomTags'
+import GetOwner from '@/components/GetOwner'
+import Icons from '@/components/Icons'
+import PropertiesContent from '@/components/PropertiesContent'
+import { useAppDispatch, useAppSelector } from '@/lib/hooks/useStore'
+import { getSemanticModelDetails } from '@/lib/store/metalakes'
+import { copyToClipboard } from '@/lib/utils'
+import { formatToDateTime, isValidDate } from '@/lib/utils/date'
+import { formatSemanticModelDefinition } from '@/lib/utils/semanticModel'
+import { ColumnTypeColorEnum } from '@/config'
+import SemanticModelExpression from './SemanticModelExpression'
+import SemanticModelAIContext from './SemanticModelAIContext'
+import SemanticModelExport from './SemanticModelExport'
+import SemanticModelExtensions from './SemanticModelExtensions'
+import CreateSemanticModelDialog from '../CreateSemanticModelDialog'
+import SemanticMetadataEditor from '../SemanticMetadataEditor'
+
+const { Title, Paragraph } = Typography
+
+const sourceFullName = source => {
+  if (!source) {
+    return '-'
+  }
+
+  return [...(source.namespace || []), source.name].filter(Boolean).join('.')
+}
+
+const renderDataType = datatype => {
+  if (!datatype) return <span>-</span>
+  const type = { DateTime: 'timestamp', DateTimeTz: 'timestamp_tz' }[datatype] 
|| datatype.toLowerCase()
+
+  return <Tag color={ColumnTypeColorEnum[type] || undefined}>{datatype}</Tag>
+}
+
+const topAlignedColumns = columns =>
+  columns.map(column => ({ ...column, onCell: () => ({ style: { verticalAlign: 
'top' } }) }))
+
+const renderColumns = columns => <span className='font-mono text-xs'>{(columns 
|| []).join(', ') || '-'}</span>
+
+const hasMetadata = entity => !!entity.aiContext || 
entity.customExtensions?.length > 0
+
+export default function SemanticModelDetailsPage() {
+  const searchParams = useSearchParams()
+  const router = useRouter()
+  const dispatch = useAppDispatch()
+  const metalake = searchParams.get('metalake')
+  const catalog = searchParams.get('catalog')
+  const schema = searchParams.get('schema')
+  const semanticModelName = searchParams.get('semanticModel')
+  const auth = useAppSelector(state => state.auth)
+  const { anthEnable } = auth
+  const store = useAppSelector(state => state.metalakes)
+  const semanticModel = store.activatedDetails?.name === semanticModelName ? 
store.activatedDetails : null
+  const [tabKey, setTabKey] = useState('Datasets')
+  const [openEdit, setOpenEdit] = useState(false)
+  const [metadataEdit, setMetadataEdit] = useState(null)
+  const [fieldContext, setFieldContext] = useState(null)
+  const [metadataDetails, setMetadataDetails] = useState(null)
+  const [expandedDefinition, setExpandedDefinition] = useState(false)
+  const [fieldSearch, setFieldSearch] = useState('')
+  const [metricSearch, setMetricSearch] = useState('')
+  const [expandedDatasets, setExpandedDatasets] = useState([])
+
+  const mounted = useRef(true)
+  useEffect(() => {
+    mounted.current = true
+
+    return () => {
+      mounted.current = false
+    }
+  }, [])
+
+  const definition = semanticModel?.definition
+  const properties = semanticModel?.properties
+  const createdAt = semanticModel?.audit?.createTime
+  const createdAtText = !createdAt || !isValidDate(createdAt) ? '-' : 
formatToDateTime(createdAt)
+  const lastModifiedAt = semanticModel?.audit?.lastModifiedTime
+  const lastModifiedText = !lastModifiedAt || !isValidDate(lastModifiedAt) ? 
'-' : formatToDateTime(lastModifiedAt)
+  const metadataObjectFullName = `${catalog}.${schema}.${semanticModelName}`
+
+  const datasets = useMemo(
+    () => (definition?.datasets || []).map(dataset => ({ ...dataset, key: 
dataset.name })),
+    [definition]
+  )
+
+  const relationships = useMemo(
+    () => (definition?.relationships || []).map(relationship => ({ 
...relationship, key: relationship.name })),
+    [definition]
+  )
+
+  const metrics = useMemo(
+    () => (definition?.metrics || []).map(metric => ({ ...metric, key: 
metric.name })),
+    [definition]
+  )
+
+  const fieldQuery = fieldSearch.trim().toLowerCase()
+
+  const filteredDatasets = useMemo(
+    () =>
+      fieldQuery
+        ? datasets
+            .map(dataset => ({
+              ...dataset,
+              fields: (dataset.fields || []).filter(field =>
+                [field.name, field.label].some(value => 
value?.toLowerCase().includes(fieldQuery))
+              )
+            }))
+            .filter(dataset => dataset.fields.length)
+        : datasets,
+    [datasets, fieldQuery]
+  )
+
+  const filteredMetrics = useMemo(
+    () => metrics.filter(metric => 
metric.name.toLowerCase().includes(metricSearch.trim().toLowerCase())),
+    [metrics, metricSearch]
+  )
+
+  const renderMetadata = (entity, label, target) => (
+    <>
+      <SemanticModelAIContext
+        context={entity?.aiContext}
+        label={`${label} AI Context`}
+        onEdit={
+          target
+            ? () => {
+                setMetadataDetails(null)
+                setMetadataEdit({ ...target, section: 'context' })
+              }
+            : undefined
+        }
+      />
+      <SemanticModelExtensions extensions={entity?.customExtensions} 
label={`${label} Vendor Extensions`} />
+    </>
+  )
+
+  const definitionText = useMemo(() => 
formatSemanticModelDefinition(definition), [definition])
+
+  const tagContent = (
+    <div>
+      <Tags readOnly={true} metadataObjectType={'semantic_model'} 
metadataObjectFullName={metadataObjectFullName} />
+    </div>
+  )
+
+  const propertyContent = <PropertiesContent properties={properties} />
+
+  const onSaved = async updated => {
+    if (!mounted.current) return
+    if (updated.name !== semanticModelName) {
+      const params = new URLSearchParams(searchParams.toString())
+      params.set('semanticModel', updated.name)
+      router.replace(`/catalogs?${params.toString()}`)
+    } else {
+      await dispatch(getSemanticModelDetails({ metalake, catalog, schema, 
semanticModel: updated.name, init: true }))
+    }
+  }
+
+  const onCopyDefinition = async () => {
+    try {
+      await copyToClipboard(definitionText)
+      message.success('Definition copied!')
+    } catch (err) {
+      console.error('Failed to copy definition: ', err)
+      message.error('Failed to copy definition')
+    }
+  }
+
+  const datasetColumns = useMemo(
+    () => [
+      {
+        title: 'Dataset Name',
+        dataIndex: 'name',
+        key: 'name',
+        fixed: 'left',
+        width: 200,
+        ellipsis: true,
+        sorter: (a, b) => 
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
+      },
+      {
+        title: 'Source',
+        dataIndex: 'source',
+        key: 'source',
+        width: 260,
+        ellipsis: true,
+        render: source => <span className='font-mono 
text-xs'>{sourceFullName(source)}</span>
+      },
+      {
+        title: 'Primary Key',
+        dataIndex: 'primaryKey',
+        key: 'primaryKey',
+        width: 180,
+        ellipsis: true,
+        render: renderColumns
+      },
+      {
+        title: 'Unique Keys',
+        dataIndex: 'uniqueKeys',
+        key: 'uniqueKeys',
+        width: 220,
+        render: keys =>
+          keys?.length ? (
+            <Space direction='vertical' size={4}>
+              {keys.map((columns, index) => (
+                <Tag key={index}>{renderColumns(columns)}</Tag>
+              ))}
+            </Space>
+          ) : (
+            <span>-</span>
+          )
+      },
+      {
+        title: 'Fields',
+        dataIndex: 'fields',
+        key: 'fields',
+        width: 80,
+        render: fields => <span>{(fields || []).length}</span>
+      },
+      {
+        title: 'Description',
+        dataIndex: 'description',
+        key: 'description',
+        ellipsis: true,
+        render: description => <span>{description || '-'}</span>
+      },
+      {
+        title: 'Actions',
+        key: 'actions',
+        fixed: 'right',
+        width: 90,
+        render: (_, record) => (
+          <Button
+            size='small'
+            aria-label={`Edit dataset ${record.name}`}
+            onClick={() => setMetadataEdit({ kind: 'dataset', name: 
record.name })}
+          >
+            Edit
+          </Button>
+        )
+      }
+    ],
+    []
+  )
+
+  const relationshipColumns = useMemo(
+    () => [
+      {
+        title: 'Relationship Name',
+        dataIndex: 'name',
+        key: 'name',
+        fixed: 'left',
+        width: 220,
+        ellipsis: true,
+        sorter: (a, b) => 
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
+      },
+      { title: 'From', dataIndex: 'from', key: 'from', width: 160, ellipsis: 
true },
+      {
+        title: 'From Columns',
+        dataIndex: 'fromColumns',
+        key: 'fromColumns',
+        width: 200,
+        ellipsis: true,
+        render: renderColumns
+      },
+      { title: 'To', dataIndex: 'to', key: 'to', width: 160, ellipsis: true },
+      {
+        title: 'To Columns',
+        dataIndex: 'toColumns',
+        key: 'toColumns',
+        ellipsis: true,
+        render: renderColumns
+      },
+      {
+        title: 'Actions',
+        key: 'actions',
+        fixed: 'right',
+        width: 90,
+        render: (_, record) => (
+          <Button
+            size='small'
+            aria-label={`Edit relationship ${record.name}`}
+            onClick={() => setMetadataEdit({ kind: 'relationship', name: 
record.name })}
+          >
+            Edit
+          </Button>
+        )
+      }
+    ],
+    []
+  )
+
+  const metricColumns = useMemo(
+    () => [
+      {
+        title: 'Metric Name',
+        dataIndex: 'name',
+        key: 'name',
+        fixed: 'left',
+        width: 200,
+        ellipsis: true,
+        sorter: (a, b) => 
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
+      },
+      {
+        title: 'Data Type',
+        dataIndex: 'datatype',
+        key: 'datatype',
+        width: 140,
+        render: renderDataType
+      },
+      {
+        title: 'Expression',
+        dataIndex: 'expression',
+        key: 'expression',
+        render: (expression, record) => <SemanticModelExpression 
expression={expression} name={record.name} />
+      },
+      {
+        title: 'Description',
+        dataIndex: 'description',
+        key: 'description',
+        ellipsis: true,
+        render: description => <span>{description || '-'}</span>
+      },
+      {
+        title: 'Actions',
+        key: 'actions',
+        fixed: 'right',
+        width: 90,
+        render: (_, record) => (
+          <Button
+            size='small'
+            aria-label={`Edit metric ${record.name}`}
+            onClick={() => setMetadataEdit({ kind: 'metric', name: record.name 
})}
+          >
+            Edit
+          </Button>
+        )
+      }
+    ],
+    []
+  )
+
+  const tabOptions = [
+    { label: 'Overview', key: 'Overview' },
+    { label: 'Datasets', key: 'Datasets' },
+    { label: 'Relationships', key: 'Relationships' },
+    { label: 'Metrics', key: 'Metrics' },
+    { label: 'Definition', key: 'Definition' }
+  ]
+
+  const {
+    resizableColumns: datasetResizableColumns,
+    components: datasetComponents,
+    tableWidth: datasetTableWidth
+  } = useAntdColumnResize(() => {
+    return { columns: topAlignedColumns(datasetColumns), minWidth: 100 }
+  }, [datasetColumns])
+
+  const {
+    resizableColumns: relationshipResizableColumns,
+    components: relationshipComponents,
+    tableWidth: relationshipTableWidth
+  } = useAntdColumnResize(() => {
+    return { columns: topAlignedColumns(relationshipColumns), minWidth: 100 }
+  }, [relationshipColumns])
+
+  const {
+    resizableColumns: metricResizableColumns,
+    components: metricComponents,
+    tableWidth: metricTableWidth
+  } = useAntdColumnResize(() => {
+    return { columns: topAlignedColumns(metricColumns), minWidth: 100 }
+  }, [metricColumns])
+
+  const expandedFieldsRow = dataset => (
+    <section aria-label={`${dataset.name} fields`} className='py-2'>
+      <div className='mb-3 flex items-center justify-between gap-3'>
+        <Typography.Text strong>Fields ({(dataset.fields || 
[]).length})</Typography.Text>
+        {hasMetadata(dataset) && (
+          <Button
+            size='small'
+            onClick={() =>
+              setMetadataDetails({
+                entity: dataset,
+                label: dataset.name,
+                target: { kind: 'dataset', name: dataset.name }
+              })
+            }
+          >
+            View dataset details
+          </Button>
+        )}
+      </div>
+      <div data-refer='semantic-model-fields-grid' className='divide-y 
divide-borderColor'>
+        {(dataset.fields || []).map(field => (
+          <section key={field.name} aria-label={`Field ${field.name}`} 
className='py-3'>
+            <div className='mb-2 flex items-start justify-between gap-3'>
+              <div className='min-w-0 break-words'>
+                <Typography.Text strong>{field.name}</Typography.Text>
+                {field.label && (
+                  <Typography.Text type='secondary' className='ml-2'>
+                    {field.label}
+                  </Typography.Text>
+                )}
+              </div>
+              <Button
+                size='small'
+                aria-label={`Edit field ${field.name}`}
+                onClick={() => setMetadataEdit({ kind: 'field', name: 
field.name, datasetName: dataset.name })}
+              >
+                Edit
+              </Button>
+            </div>
+            <div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
+              <div className='min-w-0'>
+                <div className='mb-2'>
+                  {renderDataType(field.datatype)}
+                  <Typography.Text type='secondary'>
+                    Time dimension: {field.dimension?.isTime ? 'Yes' : 'No'}
+                  </Typography.Text>
+                </div>
+                {field.description && <Paragraph 
className='break-words'>{field.description}</Paragraph>}
+                <Space wrap>
+                  {field.aiContext &&
+                    (typeof field.aiContext === 'string' || 
Object.keys(field.aiContext).length > 0) && (
+                      <Button
+                        size='small'
+                        aria-label={`${field.name} AI Context`}
+                        onClick={() => setFieldContext({ ...field, 
datasetName: dataset.name })}
+                      >
+                        AI Context
+                      </Button>
+                    )}
+                  {field.customExtensions?.length > 0 && (
+                    <Button
+                      size='small'
+                      aria-label={`${field.name} Vendor Extensions`}
+                      onClick={() =>
+                        setMetadataDetails({
+                          entity: field,
+                          label: field.name,
+                          target: { kind: 'field', name: field.name, 
datasetName: dataset.name }
+                        })
+                      }
+                    >
+                      Vendor Extensions
+                    </Button>
+                  )}
+                </Space>
+              </div>
+              <SemanticModelExpression expression={field.expression} 
name={field.name} />
+            </div>
+          </section>
+        ))}
+      </div>
+    </section>
+  )
+
+  return (
+    <div className={styles.page} data-refer='semantic-model-details'>
+      <Spin spinning={store.activatedDetailsLoading}>
+        <Flex className='mb-2' gap='small' align='flex-start'>
+          <div className='size-8'>
+            <Icons.iconify icon='mdi:graph-outline' className='my-icon-large' 
/>
+          </div>
+          <div className='relative bottom-1 min-w-0 flex-1'>
+            <Title level={3} style={{ marginBottom: '0.125rem' }}>
+              <div className='flex min-w-0 items-center gap-2'>
+                <span title={semanticModelName} className='min-w-0 truncate'>
+                  {semanticModelName || ''}
+                </span>
+                <Tooltip title='Edit'>
+                  <Button
+                    type='text'
+                    size='small'
+                    aria-label='Edit Semantic Model'
+                    icon={<Icons.Pencil className='size-4' />}
+                    disabled={store.activatedDetailsLoading || !definition}
+                    onClick={() => setOpenEdit(true)}
+                  />
+                </Tooltip>
+              </div>
+            </Title>
+            <Paragraph type='secondary' className='truncate' 
title={semanticModel?.comment} style={{ marginBottom: 0 }}>
+              {semanticModel?.comment}
+            </Paragraph>
+          </div>
+          <div className='ml-auto shrink-0'>
+            <SemanticModelExport
+              key={metadataObjectFullName}
+              metalake={metalake}
+              catalog={catalog}
+              schema={schema}
+              semanticModel={semanticModelName}
+              disabled={store.activatedDetailsLoading || !definition}
+            />
+          </div>
+        </Flex>
+      </Spin>
+      <Tabs data-refer='details-tabs' activeKey={tabKey} onChange={setTabKey} 
items={tabOptions} />
+      <Spin wrapperClassName={styles.body} 
spinning={store.activatedDetailsLoading}>
+        {tabKey === 'Overview' && (
+          <div className='min-h-0 overflow-auto'>
+            <Space split={<Divider type='vertical' />} wrap={true} 
className='mb-2'>
+              {anthEnable && (
+                <Space size={4}>
+                  <Tooltip title='Owned' placement='top'>
+                    <Icons.User className='size-4' color='grey' />
+                  </Tooltip>
+                  <GetOwner
+                    metalake={metalake}
+                    metadataObjectType={'semantic_model'}
+                    metadataObjectFullName={metadataObjectFullName}
+                  />
+                </Space>
+              )}
+              {semanticModel?.audit?.creator && (
+                <Space size={4}>
+                  <Tooltip title='Creator' placement='top'>
+                    <Icons.UserPen className='size-4' color='grey' />
+                  </Tooltip>
+                  <span>{semanticModel.audit.creator}</span>
+                </Space>
+              )}
+              <Space size={4}>
+                <Tooltip title='Created' placement='top'>
+                  <span className='inline-flex'>
+                    <Icons.iconify icon='mdi:clock-outline' className='size-4' 
props={{ color: 'grey' }} />
+                  </span>
+                </Tooltip>
+                <span>{createdAtText}</span>
+              </Space>
+              <Space size={4}>
+                <Tooltip title='Tags' placement='top'>
+                  <Icons.Tags className='size-4' color='grey' />
+                </Tooltip>
+                {store.currentEntityTags && store.currentEntityTags?.length > 
0 ? (
+                  <Popover placement='bottom' title={<span>Tags</span>} 
content={tagContent}>
+                    <a 
className='text-defaultPrimary'>{store.currentEntityTags?.length}</a>
+                  </Popover>
+                ) : (
+                  <a className='text-defaultPrimary'>0</a>
+                )}
+              </Space>
+              <Space size={4}>
+                <Tooltip title='Properties' placement='top'>
+                  <Icons.TableProperties className='size-4' color='grey' />
+                </Tooltip>
+                {properties && Object.keys(properties).length > 0 ? (
+                  <Popover placement='bottom' title={<span>Properties</span>} 
content={propertyContent}>
+                    <a 
className='text-defaultPrimary'>{Object.keys(properties).length}</a>
+                  </Popover>
+                ) : (
+                  <a className='text-defaultPrimary'>0</a>
+                )}
+              </Space>
+              {semanticModel?.audit?.lastModifier && (
+                <Space size={4} data-refer='semantic-model-last-modifier'>
+                  <Tooltip title='Last modified by'>
+                    <Icons.UserPen className='size-4' color='grey' />
+                  </Tooltip>
+                  <span>{semanticModel.audit.lastModifier}</span>
+                </Space>
+              )}
+              {lastModifiedAt && (
+                <Space size={4} data-refer='semantic-model-last-modified-time'>
+                  <Tooltip title='Last modified'>
+                    <Icons.History className='size-4' color='grey' />
+                  </Tooltip>
+                  <span>{lastModifiedText}</span>
+                </Space>
+              )}
+            </Space>
+            <Button
+              className='mb-3 block'
+              disabled={!definition}
+              onClick={() => setMetadataEdit({ kind: 'model', section: 
'context' })}
+            >
+              Edit Model AI Context
+            </Button>
+            {renderMetadata(definition, 'Model')}
+          </div>
+        )}
+        {tabKey === 'Datasets' && (
+          <>
+            <Space className='mb-3 shrink-0'>
+              <Input
+                className='w-64'
+                aria-label='Search fields'
+                placeholder='Search field name or label'
+                value={fieldSearch}
+                onChange={event => setFieldSearch(event.target.value)}
+              />
+              {fieldSearch && <Button onClick={() => setFieldSearch('')}>Clear 
field search</Button>}
+            </Space>
+            <Table
+              data-refer='semantic-model-datasets-grid'
+              className={styles.table}
+              size='small'
+              tableLayout='fixed'
+              scroll={{ x: datasetTableWidth, y: '100%' }}
+              dataSource={filteredDatasets}
+              columns={datasetResizableColumns}
+              components={datasetComponents}
+              expandable={{
+                expandedRowRender: expandedFieldsRow,
+                expandedRowKeys: fieldQuery ? filteredDatasets.map(dataset => 
dataset.key) : expandedDatasets,
+                onExpandedRowsChange: keys => {
+                  if (!fieldQuery) setExpandedDatasets(keys)
+                },
+                rowExpandable: dataset => (dataset.fields || []).length > 0 || 
hasMetadata(dataset)
+              }}
+              pagination={{ position: ['bottomCenter'], showSizeChanger: true 
}}
+            />
+          </>
+        )}
+        {tabKey === 'Relationships' && (
+          <Table
+            data-refer='semantic-model-relationships-grid'
+            className={styles.table}
+            size='small'
+            tableLayout='fixed'
+            scroll={{ x: relationshipTableWidth, y: '100%' }}
+            dataSource={relationships}
+            columns={relationshipResizableColumns}
+            components={relationshipComponents}
+            expandable={{
+              expandedRowRender: relationship =>
+                renderMetadata(relationship, relationship.name, { kind: 
'relationship', name: relationship.name }),
+              rowExpandable: hasMetadata
+            }}
+            pagination={{ position: ['bottomCenter'], showSizeChanger: true }}
+          />
+        )}
+        {tabKey === 'Metrics' && (
+          <>
+            <Space className='mb-3 shrink-0'>
+              <Input
+                aria-label='Search metrics'
+                placeholder='Search metric name'
+                value={metricSearch}
+                onChange={event => setMetricSearch(event.target.value)}
+              />
+              {metricSearch && <Button onClick={() => 
setMetricSearch('')}>Clear metric search</Button>}
+            </Space>
+            <Table
+              data-refer='semantic-model-metrics-grid'
+              className={styles.table}
+              size='small'
+              tableLayout='fixed'
+              scroll={{ x: metricTableWidth, y: '100%' }}
+              dataSource={filteredMetrics}
+              columns={metricResizableColumns}
+              components={metricComponents}
+              expandable={{
+                expandedRowRender: metric => renderMetadata(metric, 
metric.name, { kind: 'metric', name: metric.name }),
+                rowExpandable: hasMetadata
+              }}
+              pagination={{ position: ['bottomCenter'], showSizeChanger: true 
}}
+            />
+          </>
+        )}
+        {tabKey === 'Definition' &&
+          (definitionText ? (
+            <div className='flex min-h-0 flex-1 flex-col rounded border 
border-borderColor p-3'>
+              <Space className='mb-3 shrink-0'>
+                <Button onClick={() => setOpenEdit(true)}>Edit JSON 
definition</Button>
+                <Tooltip title='Copy definition' placement='top'>
+                  <button
+                    type='button'
+                    aria-label='Copy definition'
+                    className='inline-flex cursor-pointer items-center rounded 
border border-borderColor bg-transparent p-1 text-textSecondary 
transition-colors hover:text-defaultPrimary'
+                    onClick={onCopyDefinition}
+                  >
+                    <Icons.Copy className='size-4' />
+                  </button>
+                </Tooltip>
+                <Button onClick={() => setExpandedDefinition(true)}>Expand 
definition</Button>
+              </Space>
+              <pre
+                data-refer='semantic-model-definition'
+                className='m-0 min-h-0 flex-1 overflow-auto 
whitespace-pre-wrap break-all font-mono text-sm leading-6'
+              >
+                {definitionText}
+              </pre>
+            </div>
+          ) : (
+            <Paragraph type='secondary'>No definition available.</Paragraph>
+          ))}
+      </Spin>
+      {expandedDefinition && (
+        <Modal
+          open
+          title={`${semanticModelName} definition`}
+          width='100vw'
+          style={{ top: 0, maxWidth: '100vw', margin: 0, paddingBottom: 0 }}
+          styles={{
+            content: { height: '100vh', display: 'flex', flexDirection: 
'column', borderRadius: 0 },
+            body: { flex: 1, minHeight: 0, display: 'flex', flexDirection: 
'column' }
+          }}
+          footer={null}
+          onCancel={() => setExpandedDefinition(false)}
+        >
+          <Button className='mb-3 self-start' onClick={onCopyDefinition}>
+            Copy definition
+          </Button>
+          <pre className='m-0 min-h-0 flex-1 overflow-auto whitespace-pre-wrap 
break-all font-mono text-sm leading-6'>
+            {definitionText}
+          </pre>
+        </Modal>
+      )}
+      {metadataDetails && (
+        <Modal
+          open
+          title={`${metadataDetails.label} details`}
+          footer={null}
+          width={800}
+          style={{ top: 24 }}
+          styles={{ body: { maxHeight: 'calc(100vh - 140px)', overflowY: 
'auto' } }}
+          onCancel={() => setMetadataDetails(null)}
+        >
+          {renderMetadata(metadataDetails.entity, metadataDetails.label, 
metadataDetails.target)}
+        </Modal>
+      )}
+      {fieldContext && (
+        <Modal
+          open
+          title={`${fieldContext.name} AI Context`}
+          footer={null}
+          style={{ top: 24 }}
+          styles={{ body: { maxHeight: 'calc(100vh - 140px)', overflowY: 
'auto' } }}
+          onCancel={() => setFieldContext(null)}
+        >
+          <SemanticModelAIContext
+            context={fieldContext.aiContext}
+            inline
+            onEdit={() => {
+              setMetadataEdit({
+                kind: 'field',
+                name: fieldContext.name,
+                datasetName: fieldContext.datasetName,
+                section: 'context'
+              })
+              setFieldContext(null)
+            }}
+          />
+        </Modal>
+      )}
+      {metadataEdit && (
+        <SemanticMetadataEditor
+          definition={definition}

Review Comment:
   [Important] This mounts the editor with whatever `definition` currently is, 
and `definition` can be `undefined` while a modal that opens the editor is 
still on screen - which crashes the whole right panel instead of degrading.
   
   Path: open a field's `AI Context` modal (line 763) or a `View dataset 
details` modal (line 750). Both are driven by their own state snapshot, so they 
stay mounted when `semanticModel` becomes `null` at line 94 (the 
stale-detail-response case still open on this head, or the window in `page.js` 
before `resetActivatedDetails()` when the list fetch has to run first). 
Clicking `Edit AI Context` there sets `metadataEdit` and this line renders 
`SemanticMetadataEditor` with `definition === undefined`. In 
`semanticMetadata.js:30` `getSemanticMetadataEntity` then evaluates 
`definition.datasets.find(...)` (or `definition['datasets']` for the other 
kinds) and throws `TypeError: Cannot read properties of undefined`, during 
render. For `kind: 'model'` it returns `undefined` and 
`semanticMetadataFormValues` throws on `entity.expression?.dialects` 
(semanticMetadata.js:45), with `entity.aiContext` in 
SemanticMetadataEditor.js:260 as a third unguarded read.
   
   Suggestion: render only when the definition is present (`{metadataEdit && 
definition && ...}`), close the dependent modals when the model is cleared, and 
make `getSemanticMetadataEntity`/`semanticMetadataFormValues` return the same 
"metadata no longer exists" error they already raise on the save path 
(semanticMetadata.js:38) instead of dereferencing.
   
   Verified by: reading SemanticModelDetailsPage.js:94, 750-798 and 
semanticMetadata.js:29-50 in this checkout; `semanticMetadata.test.js:151` 
covers only the save-path variant, so nothing exercises the mount path.



##########
web-v2/web/src/lib/utils/ossiePreview.js:
##########
@@ -0,0 +1,51 @@
+/*
+ * 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.
+ */
+
+import { load, JSON_SCHEMA } from 'js-yaml'
+import { isPlainObject } from 'lodash-es'
+
+/** Reads only the import preview; the original document is sent unchanged for 
server validation. */
+export const parseOssiePreview = (document, format) => {
+  if (!document.trim()) throw new Error('The selected file is empty.')
+  const model = format === 'json' ? JSON.parse(document) : load(document, { 
schema: JSON_SCHEMA })
+  if (!isPlainObject(model)) throw new Error('The OSSIE document must be an 
object.')
+  if (typeof model.name !== 'string' || !model.name.trim()) throw new 
Error('The document must declare a model name.')

Review Comment:
   [Nit] The preview does not check `version`, which the server requires: 
`OssieDocumentConverter.validateVersion` 
(core/.../OssieDocumentConverter.java:281-286) rejects a document whose root 
`version` is absent, non-textual or blank with `$.version: must be a non-empty 
string`.
   
   So a document missing `version` previews cleanly with its name and counts, 
and only fails after Import is clicked - the one class of error this preview 
exists to catch before a request. Checking it here alongside the name, and 
showing the version in the preview beside the destination, would keep the "no 
import was attempted" stage honest.
   
   Verified by: reading OssieDocumentConverter.java:253-286 against this 
function; `ossiePreview.test.js` has no case for a missing version.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to