mchades commented on code in PR #12900: URL: https://github.com/apache/gravitino/pull/12900#discussion_r4267264066
########## web-v2/web/src/app/catalogs/rightContent/entitiesContent/SemanticModelDetailsPage.js: ########## @@ -0,0 +1,700 @@ +/* + * 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 { useMemo, useState } from 'react' +import { + Button, + Divider, + Flex, + Modal, + Popover, + Space, + Spin, + Table, + Tabs, + Tag, + Tooltip, + Typography, + message +} from 'antd' +import { useAntdColumnResize } from 'react-antd-column-resize' +import useResizeObserver from 'use-resize-observer' +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 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 renderExpression = expression => { + const dialects = expression?.dialects || [] + + if (!dialects.length) { + return <span>-</span> + } + + return ( + <div className='min-w-0 space-y-2'> + {dialects.map((dialect, index) => ( + <div key={`${dialect?.dialect}-${index}`}> + <Tag>{dialect?.dialect}</Tag> + <pre className='m-0 max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-xs [overflow-wrap:anywhere]'> Review Comment: UX: Keep multi-dialect list rows compact Rendering every dialect in every row makes even four short expressions produce a tall metric/field row, which makes scanning and comparing entries difficult. Please show a concise expression preview and dialect-count indicator in the list, with a dialect switcher or expandable detail area for the complete SQL and a way to view all dialects. Preserve the existing newline and overflow handling. ########## web-v2/web/src/app/catalogs/rightContent/SemanticMetadataEditor.js: ########## @@ -0,0 +1,249 @@ +/* + * 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 { useRef, useState } from 'react' +import { Alert, Button, Form, Input, Modal, Select, Space, Typography } 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 dispatch = useAppDispatch() + const [form] = Form.useForm() + const [saving, setSaving] = useState(false) + const [errors, setErrors] = useState([]) + + // 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 save = async () => { + try { + await form.validateFields() + } catch { + 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(['Unable to save metadata. Your edits are retained; please try again.']) + + return + } + await onSaved(result.payload) + onClose() + } finally { + setSaving(false) + } + } + + return ( + <Modal + open + title={title} + width={760} + onCancel={onClose} Review Comment: UX: Confirm before discarding unsaved metadata edits onCancel immediately closes and unmounts the editor, and both outside clicks and Escape are enabled whenever a save is not running. An accidental dismissal therefore loses edited descriptions, SQL and AI guidance without notice. Please detect changes from the initial values and ask whether to discard them before closing; an unchanged form should still close immediately. ########## web-v2/web/src/app/catalogs/rightContent/entitiesContent/SemanticModelDetailsPage.js: ########## @@ -0,0 +1,700 @@ +/* + * 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 { useMemo, useState } from 'react' +import { + Button, + Divider, + Flex, + Modal, + Popover, + Space, + Spin, + Table, + Tabs, + Tag, + Tooltip, + Typography, + message +} from 'antd' +import { useAntdColumnResize } from 'react-antd-column-resize' +import useResizeObserver from 'use-resize-observer' +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 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 renderExpression = expression => { + const dialects = expression?.dialects || [] + + if (!dialects.length) { + return <span>-</span> + } + + return ( + <div className='min-w-0 space-y-2'> + {dialects.map((dialect, index) => ( + <div key={`${dialect?.dialect}-${index}`}> + <Tag>{dialect?.dialect}</Tag> + <pre className='m-0 max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-xs [overflow-wrap:anywhere]'> + {dialect?.expression} + </pre> + </div> + ))} + </div> + ) +} + +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 + +const renderMetadata = (entity, label) => ( + <> + <SemanticModelAIContext context={entity?.aiContext} label={`${label} AI Context`} /> + <SemanticModelExtensions extensions={entity?.customExtensions} label={`${label} Vendor Extensions`} /> + </> +) + +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 + const [tabKey, setTabKey] = useState('Datasets') + const [openEdit, setOpenEdit] = useState(false) + const [metadataEdit, setMetadataEdit] = useState(null) + const [fieldContext, setFieldContext] = useState(null) + const { ref, width } = useResizeObserver() + + 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 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 (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', + 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', + width: 90, + render: (_, record) => ( + <Button + size='small' + aria-label={`Edit dataset ${record.name}`} + onClick={() => setMetadataEdit({ kind: 'dataset', name: record.name })} + > + Edit + </Button> + ) + } + ], + [] + ) + + const fieldColumns = useMemo( + () => [ + { + title: 'Field Name', + dataIndex: 'name', + key: 'name', + width: 220, + render: (name, field) => ( + <div className='break-words'> + <div>{name}</div> + {field.label && <Typography.Text type='secondary'>{field.label}</Typography.Text>} + <div> + {' '} + {field.aiContext && (typeof field.aiContext === 'string' || Object.keys(field.aiContext).length > 0) && ( + <Button + size='small' + type='link' + aria-label={`${field.name} AI Context`} + onClick={() => setFieldContext(field)} + > + AI Context + </Button> + )} + </div> + </div> + ) + }, + { + title: 'Data Type', + dataIndex: 'datatype', + key: 'datatype', + width: 140, + render: datatype => (datatype ? <Tag>{datatype}</Tag> : <span>-</span>) + }, + { + title: 'Time Dimension', + dataIndex: 'dimension', + key: 'dimension', + width: 140, + render: dimension => <span>{dimension?.isTime ? 'Yes' : 'No'}</span> + }, + { + title: 'Expression', + dataIndex: 'expression', + key: 'expression', + render: renderExpression + }, + { + title: 'Description', + dataIndex: 'description', + key: 'description', + ellipsis: true, + render: description => <span>{description || '-'}</span> + } + ], + [] + ) + + const relationshipColumns = useMemo( + () => [ + { + title: 'Relationship Name', + dataIndex: 'name', + key: 'name', + 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', + 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', + width: 200, + ellipsis: true, + sorter: (a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()) + }, + { + title: 'Data Type', + dataIndex: 'datatype', + key: 'datatype', + width: 140, + render: datatype => (datatype ? <Tag>{datatype}</Tag> : <span>-</span>) + }, + { + title: 'Expression', + dataIndex: 'expression', + key: 'expression', + render: renderExpression + }, + { + title: 'Description', + dataIndex: 'description', + key: 'description', + ellipsis: true, + render: description => <span>{description || '-'}</span> + }, + { + title: 'Actions', + key: 'actions', + 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: '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 => ( + <div> + {renderMetadata(dataset, dataset.name)} + <Table + data-refer='semantic-model-fields-grid' + size='small' + rowKey={field => field.name} + dataSource={dataset.fields || []} + tableLayout='fixed' + scroll={{ x: 1000 }} + columns={topAlignedColumns([ + ...fieldColumns, + { + title: 'Actions', + key: 'actions', + width: 90, + render: (_, field) => ( + <Space direction='vertical' size={4}> + <Button + size='small' + aria-label={`Edit field ${field.name}`} + onClick={() => setMetadataEdit({ kind: 'field', name: field.name, datasetName: dataset.name })} + > + Edit + </Button> + </Space> + ) + } + ])} + pagination={false} + expandable={{ + expandedRowRender: field => ( + <SemanticModelExtensions extensions={field.customExtensions} label={`${field.name} Vendor Extensions`} /> + ), + rowExpandable: field => field.customExtensions?.length > 0 + }} + /> + </div> + ) + + return ( + <> + <Spin spinning={store.activatedDetailsLoading}> + <Flex className='mb-2' gap='small' align='flex-start' ref={ref}> + <div className='size-8'> + <Icons.iconify icon='mdi:graph-outline' className='my-icon-large' /> + </div> + <div className='grow-1 relative bottom-1'> + <Title level={3} style={{ marginBottom: '0.125rem' }}> + <Space> + <span + title={semanticModelName} + className='min-w-10 truncate' + style={{ maxWidth: `calc(${width}px - 90px)`, display: 'inherit' }} + > + {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> + </Space> + </Title> + <Paragraph + type='secondary' + className='truncate' + title={semanticModel?.comment} + style={{ marginBottom: 0, maxWidth: `calc(${width}px - 56px)` }} + > + {semanticModel?.comment} + </Paragraph> + </div> + </Flex> + <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> + </Spin> + <Flex justify='space-between' align='start' gap='small' wrap> + <Button className='mb-3' disabled={!definition} onClick={() => setMetadataEdit({ kind: 'model' })}> + Edit Model AI Context + </Button> + <SemanticModelExport + key={metadataObjectFullName} + metalake={metalake} + catalog={catalog} + schema={schema} + semanticModel={semanticModelName} + disabled={store.activatedDetailsLoading || !definition} + /> + </Flex> + {renderMetadata(definition, 'Model')} + <Tabs data-refer='details-tabs' activeKey={tabKey} onChange={setTabKey} items={tabOptions} /> Review Comment: UX: Give the main content more space above the fold Model-level AI Context and Vendor Extensions sit above every detail tab, even while browsing metrics or relationships. In the populated four-dialect example, the header and metadata sections push the table down far enough that only one metric is fully visible at 1512×861. Please move this model-level content into an Overview tab or secondary panel, retaining clear access to it, and keep the shared header compact with the name, summary and primary actions. ########## web-v2/web/src/app/catalogs/rightContent/SemanticMetadataEditor.js: ########## @@ -0,0 +1,249 @@ +/* + * 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 { useRef, useState } from 'react' +import { Alert, Button, Form, Input, Modal, Select, Space, Typography } 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 dispatch = useAppDispatch() + const [form] = Form.useForm() + const [saving, setSaving] = useState(false) + const [errors, setErrors] = useState([]) + + // 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 save = async () => { + try { + await form.validateFields() + } catch { + 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(['Unable to save metadata. Your edits are retained; please try again.']) + + return + } + await onSaved(result.payload) + onClose() + } finally { + setSaving(false) + } + } + + return ( + <Modal + open + title={title} + width={760} + onCancel={onClose} + onOk={save} + okText='Save' + confirmLoading={saving} + cancelButtonProps={{ disabled: saving }} + closable={!saving} + maskClosable={!saving} + keyboard={!saving} + > + <div className='overflow-auto pr-2' style={{ maxHeight: '65vh' }}> + {errors.length > 0 && ( + <Alert + className='mb-3' + type='error' + showIcon + message='Could not save metadata' + description={ + <ul className='m-0 pl-5'> + {errors.map(error => ( + <li key={error}>{error}</li> + ))} + </ul> + } + /> + )} + <Form form={form} initialValues={initial} layout='vertical' disabled={saving} name='semanticMetadata'> + {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> + <Typography.Title level={5}>Dialect expressions</Typography.Title> + <Form.List name='dialects'> + {(fields, { add, remove }) => ( + <> + {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 }]} + > + <Input /> + </Form.Item> + <Form.Item + {...rest} + name={[name, 'expression']} + label='SQL expression' + rules={[{ required: true, whitespace: true }]} + > + <Input.TextArea className='font-mono' autoSize={{ minRows: 3, maxRows: 8 }} /> + </Form.Item> + <Button onClick={() => remove(name)} aria-label={`Remove dialect ${name + 1}`}> + Remove dialect + </Button> + </div> + ))} + <Button className='mb-3' onClick={() => add({ dialect: '', expression: '' })}> + Add dialect + </Button> + </> + )} + </Form.List> + </> + )} + {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'}> + <Select mode='tags' /> + </Form.Item> + ))} + </> + )} + <Typography.Title level={5}>AI Context</Typography.Title> + <Form.Item name='contextFormat' label='Guidance format'> Review Comment: UX: Separate basic metadata, expressions and AI Context in the editor AI Context comes after the complete list of dialect editors, so changing one instruction requires scrolling past every SQL form. Please organize the editor into Basic information, Expressions and AI Context sections/tabs while preserving drafts across navigation. Provide an edit action from the AI Context view that opens the corresponding editor section directly; keep Save/Cancel readily accessible. ########## web-v2/web/src/app/catalogs/rightContent/entitiesContent/SemanticModelDetailsPage.js: ########## @@ -0,0 +1,700 @@ +/* + * 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 { useMemo, useState } from 'react' +import { + Button, + Divider, + Flex, + Modal, + Popover, + Space, + Spin, + Table, + Tabs, + Tag, + Tooltip, + Typography, + message +} from 'antd' +import { useAntdColumnResize } from 'react-antd-column-resize' +import useResizeObserver from 'use-resize-observer' +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 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 renderExpression = expression => { + const dialects = expression?.dialects || [] + + if (!dialects.length) { + return <span>-</span> + } + + return ( + <div className='min-w-0 space-y-2'> + {dialects.map((dialect, index) => ( + <div key={`${dialect?.dialect}-${index}`}> + <Tag>{dialect?.dialect}</Tag> + <pre className='m-0 max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-xs [overflow-wrap:anywhere]'> + {dialect?.expression} + </pre> + </div> + ))} + </div> + ) +} + +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 + +const renderMetadata = (entity, label) => ( + <> + <SemanticModelAIContext context={entity?.aiContext} label={`${label} AI Context`} /> + <SemanticModelExtensions extensions={entity?.customExtensions} label={`${label} Vendor Extensions`} /> + </> +) + +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 + const [tabKey, setTabKey] = useState('Datasets') + const [openEdit, setOpenEdit] = useState(false) + const [metadataEdit, setMetadataEdit] = useState(null) + const [fieldContext, setFieldContext] = useState(null) + const { ref, width } = useResizeObserver() + + 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 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 (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', + 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', + width: 90, + render: (_, record) => ( + <Button + size='small' + aria-label={`Edit dataset ${record.name}`} + onClick={() => setMetadataEdit({ kind: 'dataset', name: record.name })} + > + Edit + </Button> + ) + } + ], + [] + ) + + const fieldColumns = useMemo( + () => [ + { + title: 'Field Name', + dataIndex: 'name', + key: 'name', + width: 220, + render: (name, field) => ( + <div className='break-words'> + <div>{name}</div> + {field.label && <Typography.Text type='secondary'>{field.label}</Typography.Text>} + <div> + {' '} + {field.aiContext && (typeof field.aiContext === 'string' || Object.keys(field.aiContext).length > 0) && ( + <Button + size='small' + type='link' + aria-label={`${field.name} AI Context`} + onClick={() => setFieldContext(field)} + > + AI Context + </Button> + )} + </div> + </div> + ) + }, + { + title: 'Data Type', + dataIndex: 'datatype', + key: 'datatype', + width: 140, + render: datatype => (datatype ? <Tag>{datatype}</Tag> : <span>-</span>) + }, + { + title: 'Time Dimension', + dataIndex: 'dimension', + key: 'dimension', + width: 140, + render: dimension => <span>{dimension?.isTime ? 'Yes' : 'No'}</span> + }, + { + title: 'Expression', + dataIndex: 'expression', + key: 'expression', + render: renderExpression + }, + { + title: 'Description', + dataIndex: 'description', + key: 'description', + ellipsis: true, + render: description => <span>{description || '-'}</span> + } + ], + [] + ) + + const relationshipColumns = useMemo( + () => [ + { + title: 'Relationship Name', + dataIndex: 'name', + key: 'name', + 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', + 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', + width: 200, + ellipsis: true, + sorter: (a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()) + }, + { + title: 'Data Type', + dataIndex: 'datatype', + key: 'datatype', + width: 140, + render: datatype => (datatype ? <Tag>{datatype}</Tag> : <span>-</span>) + }, + { + title: 'Expression', + dataIndex: 'expression', + key: 'expression', + render: renderExpression + }, + { + title: 'Description', + dataIndex: 'description', + key: 'description', + ellipsis: true, + render: description => <span>{description || '-'}</span> + }, + { + title: 'Actions', + key: 'actions', + 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: '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 => ( + <div> + {renderMetadata(dataset, dataset.name)} + <Table + data-refer='semantic-model-fields-grid' + size='small' + rowKey={field => field.name} + dataSource={dataset.fields || []} + tableLayout='fixed' + scroll={{ x: 1000 }} + columns={topAlignedColumns([ + ...fieldColumns, + { + title: 'Actions', + key: 'actions', + width: 90, + render: (_, field) => ( + <Space direction='vertical' size={4}> + <Button + size='small' + aria-label={`Edit field ${field.name}`} + onClick={() => setMetadataEdit({ kind: 'field', name: field.name, datasetName: dataset.name })} + > + Edit + </Button> + </Space> + ) + } + ])} + pagination={false} + expandable={{ + expandedRowRender: field => ( + <SemanticModelExtensions extensions={field.customExtensions} label={`${field.name} Vendor Extensions`} /> + ), + rowExpandable: field => field.customExtensions?.length > 0 + }} + /> + </div> + ) + + return ( + <> + <Spin spinning={store.activatedDetailsLoading}> + <Flex className='mb-2' gap='small' align='flex-start' ref={ref}> + <div className='size-8'> + <Icons.iconify icon='mdi:graph-outline' className='my-icon-large' /> + </div> + <div className='grow-1 relative bottom-1'> + <Title level={3} style={{ marginBottom: '0.125rem' }}> + <Space> + <span + title={semanticModelName} + className='min-w-10 truncate' + style={{ maxWidth: `calc(${width}px - 90px)`, display: 'inherit' }} + > + {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> + </Space> + </Title> + <Paragraph + type='secondary' + className='truncate' + title={semanticModel?.comment} + style={{ marginBottom: 0, maxWidth: `calc(${width}px - 56px)` }} + > + {semanticModel?.comment} + </Paragraph> + </div> + </Flex> + <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> + </Spin> + <Flex justify='space-between' align='start' gap='small' wrap> + <Button className='mb-3' disabled={!definition} onClick={() => setMetadataEdit({ kind: 'model' })}> + Edit Model AI Context + </Button> + <SemanticModelExport + key={metadataObjectFullName} + metalake={metalake} + catalog={catalog} + schema={schema} + semanticModel={semanticModelName} + disabled={store.activatedDetailsLoading || !definition} + /> + </Flex> + {renderMetadata(definition, 'Model')} + <Tabs data-refer='details-tabs' activeKey={tabKey} onChange={setTabKey} items={tabOptions} /> + <Spin spinning={store.activatedDetailsLoading}> + {tabKey === 'Datasets' && ( + <Table + data-refer='semantic-model-datasets-grid' + size='small' + tableLayout='fixed' + scroll={{ x: datasetTableWidth, y: 'max(20rem, calc(100vh - 37rem))' }} + dataSource={datasets} + columns={datasetResizableColumns} + components={datasetComponents} + expandable={{ + expandedRowRender: expandedFieldsRow, + rowExpandable: dataset => (dataset.fields || []).length > 0 || hasMetadata(dataset) + }} + pagination={{ position: ['bottomCenter'], showSizeChanger: true }} + /> + )} + {tabKey === 'Relationships' && ( + <Table + data-refer='semantic-model-relationships-grid' + size='small' + tableLayout='fixed' + scroll={{ x: relationshipTableWidth, y: 'max(20rem, calc(100vh - 37rem))' }} + dataSource={relationships} + columns={relationshipResizableColumns} + components={relationshipComponents} + expandable={{ + expandedRowRender: relationship => renderMetadata(relationship, relationship.name), + rowExpandable: hasMetadata + }} + pagination={{ position: ['bottomCenter'], showSizeChanger: true }} + /> + )} + {tabKey === 'Metrics' && ( + <Table + data-refer='semantic-model-metrics-grid' + size='small' + tableLayout='fixed' + scroll={{ x: metricTableWidth, y: 'max(20rem, calc(100vh - 37rem))' }} + dataSource={metrics} Review Comment: Follow-up UX: Search fields and metrics within a model As a non-blocking follow-up, add filtering within the detail page by field technical name/label and metric name. The schema-level search only helps find models; larger definitions still require manually opening datasets and scanning metric pages. Keep fields grouped under their datasets, reveal groups containing matches, and make it clear how to clear the filter. ########## web-v2/web/src/app/catalogs/rightContent/SemanticMetadataEditor.js: ########## @@ -0,0 +1,249 @@ +/* + * 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 { useRef, useState } from 'react' +import { Alert, Button, Form, Input, Modal, Select, Space, Typography } 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 dispatch = useAppDispatch() + const [form] = Form.useForm() + const [saving, setSaving] = useState(false) + const [errors, setErrors] = useState([]) + + // 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 save = async () => { + try { + await form.validateFields() + } catch { + 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(['Unable to save metadata. Your edits are retained; please try again.']) Review Comment: UX: Keep actionable validation and save errors beside the draft Every failed update leaves the same generic retry message in the dialog. Please retain the useful server reason here, even if it is also shown in a transient toast, and associate validation errors with the relevant inputs where possible. Reveal/focus the first invalid field or section. Invalid definitions and permission failures need an explanation of what to correct; simply retrying the unchanged form cannot resolve them. ########## web-v2/web/src/app/catalogs/rightContent/ImportSemanticModelDialog.js: ########## @@ -0,0 +1,115 @@ +/* + * 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 { useState } from 'react' +import { Alert, Button, Modal, Space, Typography, Upload, message } from 'antd' +import { UploadOutlined } from '@ant-design/icons' +import { importSemanticModelApi } from '@/lib/api/semanticModels' +import { useAppDispatch } from '@/lib/hooks/useStore' +import { fetchSemanticModels } from '@/lib/store/metalakes' +import { semanticModelErrorMessage } from '@/lib/utils/semanticModel' + +export default function ImportSemanticModelDialog({ open, setOpen, metalake, catalog, schema }) { + const dispatch = useAppDispatch() + const [file, setFile] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + const importDocument = async () => { + if (!file || loading) return + setLoading(true) + setError('') + try { + const document = await file.text() + if (!document.trim()) throw new Error('The selected file is empty.') + + const response = await importSemanticModelApi({ + metalake, + catalog, + schema, + document, + format: file.name.toLowerCase().endsWith('.json') ? 'json' : 'yaml' + }) + if (!response?.semanticModel) throw new Error('Import returned an empty response.') + await dispatch(fetchSemanticModels({ metalake, catalog, schema, init: true })) + message.success('Semantic Model imported') + setOpen(false) + } catch (err) { + setError(semanticModelErrorMessage(err, 'Failed to import the OSSIE document.')) + } finally { + setLoading(false) + } + } + + return ( + <Modal + title='Import OSSIE document' + open={open} + onCancel={() => setOpen(false)} + onOk={importDocument} + okText='Import' + confirmLoading={loading} + okButtonProps={{ disabled: !file, 'data-refer': 'import-semantic-model-submit' }} + cancelButtonProps={{ disabled: loading }} + closable={!loading} + maskClosable={!loading} + keyboard={!loading} + > + <Space direction='vertical' className='w-full' size='middle'> + <Typography.Paragraph className='mb-0'> + Select a standalone OSSIE YAML or JSON document to create a Semantic Model in this schema. + </Typography.Paragraph> + <Upload + accept='.yaml,.yml,.json' + maxCount={1} + disabled={loading} + fileList={file ? [file] : []} + beforeUpload={selected => { Review Comment: Follow-up UX: Preview the OSSIE document before import As a non-blocking follow-up, show the parsed model name, target catalog/schema and dataset/field/relationship/metric counts after file selection, before the import is submitted. The filename alone does not identify the model that will be created. Surface parsing errors at this preview step and keep server validation authoritative. -- 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]
