laserninja commented on code in PR #12900:
URL: https://github.com/apache/gravitino/pull/12900#discussion_r4297082303
##########
web-v2/web/src/lib/store/metalakes/index.js:
##########
@@ -2364,6 +2535,25 @@ export const appMetalakesSlice = createSlice({
currentEntityPolicies: []
},
reducers: {
+ setActiveCatalogEntity(state, action) {
+ const key = catalogEntityKey(action.payload)
+ const schemaKey = action.payload.schema ?
semanticModelSchemaKey(action.payload) : null
+ if (state.activeSemanticModelSchema !== schemaKey) state.semanticModels
= []
+ state.activeSemanticModelSchema = schemaKey
+ if (state.activeCatalogEntity === key) return
+ state.activeCatalogEntity = key
+ state.activatedDetails = null
Review Comment:
Fixed the version-route save/refresh path in 17709890c. The current version
now travels from the parent model dialog through updateModel into
getModelDetails, so the refresh matches the active version identity without
relaxing the guard for other models. Tests cover comment/property updates from
model and version URLs, plus delayed saves after switching model or namespace.
Chromium confirms that both the comment and property shown on the version route
update after saving. I also added the positive-path table/fileset/topic
detail-loading coverage mentioned in the review summary, including table
columns and partition/distribution properties.
##########
web-v2/web/src/app/catalogs/rightContent/SemanticMetadataEditor.js:
##########
@@ -0,0 +1,390 @@
+/*
+ * 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(props) {
+ try {
+ getSemanticMetadataEntity(props.definition, props.target)
+ } catch (error) {
+ return (
+ <Modal
+ open
+ title='Metadata unavailable'
+ onCancel={props.onClose}
+ footer={<Button onClick={props.onClose}>Close</Button>}
+ >
+ <Alert type='error' showIcon message={error.message} />
+ </Modal>
+ )
+ }
+
+ return <SemanticMetadataForm {...props} />
+}
+
+function SemanticMetadataForm({ 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 }] }
Review Comment:
Added a shared pre-save check in 17709890c for both definition editors.
Before replaceDefinition, the update thunk re-reads the current model and
compares its definition with the editor snapshot; a mismatch or failed
verification prevents the write. Both dialogs retain the draft and show
recovery guidance. Tests and Chromium checks cover concurrent edits in both
dialogs, unchanged definitions, and failed verification reads. This remains a
best-effort check: a writer can still change the definition between the GET and
PUT, and closing that race requires a server-side expected-version token.
##########
web-v2/web/src/lib/utils/semanticMetadata.js:
##########
@@ -0,0 +1,97 @@
+/*
+ * 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 { cloneDeep, isEqual } from 'lodash-es'
+
+const editableProperties = {
+ model: [],
+ dataset: ['description'],
+ field: ['label', 'description', 'datatype'],
Review Comment:
Added a clearable Time dimension selector to the field editor Basic
information tab in 17709890c. Yes and No write the corresponding boolean;
leaving it unset preserves an absent dimension, and explicitly clearing it
removes isTime and an otherwise empty dimension object. Additional dimension
metadata is preserved. Utility/component tests cover the states, and Chromium
verifies the field edit and saved definition.
##########
web-v2/web/src/lib/utils/semanticMetadata.js:
##########
@@ -0,0 +1,97 @@
+/*
+ * 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 { cloneDeep, isEqual } from 'lodash-es'
+
+const editableProperties = {
+ model: [],
+ dataset: ['description'],
+ field: ['label', 'description', 'datatype'],
+ metric: ['description', 'datatype'],
+ relationship: ['from', 'to', 'fromColumns', 'toColumns']
+}
+
+const missingMetadataMessage = 'The selected metadata no longer exists. Reload
the model and try again.'
+
+/** Finds the selected entity without flattening dataset-scoped field names. */
+export const getSemanticMetadataEntity = (definition, target) => {
+ const entity =
+ target.kind === 'model'
+ ? definition
+ : target.kind === 'field'
+ ? definition?.datasets
+ ?.find(dataset => dataset.name === target.datasetName)
+ ?.fields?.find(field => field.name === target.name)
+ : definition?.[`${target.kind}s`]?.find(item => item.name ===
target.name)
+ if (!entity) throw new Error(missingMetadataMessage)
+
+ return entity
+}
+
+/** Supplies form values while keeping the original wire representation
separate. */
+export const semanticMetadataFormValues = entity => {
+ if (!entity) throw new Error(missingMetadataMessage)
+
+ return {
+ ...cloneDeep(entity),
+ dialects: cloneDeep(entity.expression?.dialects || []),
+ contextFormat: typeof entity.aiContext === 'string' ? 'text' :
'structured',
+ contextText: typeof entity.aiContext === 'string' ? entity.aiContext : '',
+ instructions: typeof entity.aiContext === 'string' ? entity.aiContext :
entity.aiContext?.instructions || '',
+ synonyms: cloneDeep(entity.aiContext?.synonyms || []),
+ examples: cloneDeep(entity.aiContext?.examples || [])
+ }
+}
+
+/** Applies only edited form values, preserving every other entity and
extension in the definition. */
+export const updateSemanticMetadata = (definition, target, values) => {
+ const updated = cloneDeep(definition)
+ const entity = getSemanticMetadataEntity(updated, target)
+ const initial = semanticMetadataFormValues(entity)
+ for (const key of editableProperties[target.kind]) {
+ const value = key === 'datatype' && values[key] === '' ? null : values[key]
+ if (!isEqual(initial[key], value)) entity[key] = value ?? null
+ }
+ if (['field', 'metric'].includes(target.kind) && !isEqual(initial.dialects,
values.dialects)) {
+ entity.expression = { ...entity.expression, dialects:
cloneDeep(values.dialects) }
+ }
+
+ if (values.contextFormat === 'text') {
+ if (initial.contextFormat !== 'text' || initial.contextText !==
values.contextText) {
+ // Existing structured guidance stays structured so its extra keys
cannot be discarded.
+ if (entity.aiContext && typeof entity.aiContext === 'object') {
+ entity.aiContext.instructions = values.contextText
+ } else {
+ entity.aiContext = values.contextText
Review Comment:
Fixed in 17709890c. Clearing optional labels, descriptions, or text guidance
now removes the key instead of persisting an empty string. Clearing structured
guidance also removes empty known fields and drops the context when nothing
remains, including when synonym/example lists were already empty. Unknown
context keys and vendor extensions stay intact. Tests cover empty/whitespace
input and structured cleanup; Chromium confirms the cleared keys are absent
from the saved definition.
##########
web-v2/web/src/app/catalogs/page.js:
##########
@@ -332,33 +357,50 @@ const CatalogsListPage = () => {
}
}
if (func) {
- store.functions.length === 0 && (await dispatch(fetchFunctions({
init: true, metalake, catalog, schema })))
- await dispatch(setActivatedDetailsLoading(true))
- await dispatch(getFunctionDetails({ init: true, metalake, catalog,
schema, functionName: func }))
- await dispatch(setActivatedDetailsLoading(false))
+ store.functions.length === 0 && (await load(fetchFunctions({ init:
true, metalake, catalog, schema })))
+ await load(setActivatedDetailsLoading(true))
+ await load(getFunctionDetails({ init: true, metalake, catalog,
schema, functionName: func }))
+ await load(setActivatedDetailsLoading(false))
}
if (view) {
- store.views.length === 0 && (await dispatch(fetchViews({ init:
true, metalake, catalog, schema })))
- await dispatch(setActivatedDetailsLoading(true))
- await dispatch(getViewDetails({ init: true, metalake, catalog,
schema, view }))
- await dispatch(setActivatedDetailsLoading(false))
+ store.views.length === 0 && (await load(fetchViews({ init: true,
metalake, catalog, schema })))
+ await load(setActivatedDetailsLoading(true))
+ await load(getViewDetails({ init: true, metalake, catalog, schema,
view }))
+ await load(setActivatedDetailsLoading(false))
}
- }
- if (paramsSize === 6 && version) {
- if (!store.catalogs.length) {
- await dispatch(fetchCatalogs({ metalake }))
- await dispatch(fetchSchemas({ metalake, catalog, catalogType }))
- await dispatch(fetchModels({ init: true, page: 'schemas',
metalake, catalog, schema }))
+ if (semanticModel) {
+ if (canceled) return
+ store.semanticModels.length === 0 &&
+ (await load(fetchSemanticModels({ init: true, metalake, catalog,
schema })))
+ if (canceled) return
+ load(resetActivatedDetails())
+ load(setActivatedDetailsLoading(true))
+
+ const detailRequest = load(
+ getSemanticModelDetails({ init: true, metalake, catalog, schema,
semanticModel })
+ )
+ await detailRequest
+ if (canceled) return
+ load(setActivatedDetailsLoading(false))
+ load(
+ getCurrentEntityTags({
+ init: true,
+ metalake,
+ metadataObjectType: 'semantic_model',
Review Comment:
This was an omission. In 17709890c the Semantic Model route loads policies
using semantic_model and the full model name, and Overview displays the policy
count with the existing policy component in its popover. Added navigation and
component tests for the metadata identity and policy display, and verified the
associated policy in Chromium.
##########
web-v2/web/src/app/catalogs/rightContent/entitiesContent/SchemaDetailsPage.js:
##########
@@ -804,6 +913,62 @@ export default function SchemaDetailsPage() {
components={viewComponents}
/>
</>
+ ) : tabKey === 'Semantic Models' ? (
+ <>
+ <Flex justify='flex-end' className='mb-4' gap='middle' wrap>
+ <Search
+ className='w-full sm:w-64'
+ name='searchSemanticModelInput'
+ placeholder='Search...'
+ value={search}
+ onChange={onSearchTable}
+ />
+ <Space wrap>
+ <Button onClick={() => setOpenImportSemanticModel(true)}>Import
OSSIE</Button>
+ <Button
+ data-refer='create-semantic-model-btn'
+ type='primary'
+ icon={<PlusOutlined />}
+ onClick={() => {
+ setEditSemanticModel('')
+ setOpenSemanticModel(true)
+ }}
+ >
+ Create Semantic Model
+ </Button>
+ </Space>
+ </Flex>
+ <Spin spinning={store.tableLoading}>
Review Comment:
Fixed in 17709890c by deriving list loading from the current schema request
record and its in-flight list request ID. The tab now shows loading while its
model list is pending, with the existing operation spinner still in place. The
list loading state clears on success, failure, or abort. Component tests cover
all three outcomes and an unrelated schema request; a delayed API response in
Chromium now shows the spinner until the list arrives.
--
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]