laserninja commented on code in PR #12900: URL: https://github.com/apache/gravitino/pull/12900#discussion_r4290056353
########## 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: Fixed in f82d2dd99. Uniqueness is now a Form.List rule, with explicit list revalidation when a nested dialect changes because Ant Design does not do that automatically. The error clears immediately after either renaming or removing the duplicate, without another Save. Both correction paths have component tests; the rename path also passes in the production browser. ########## 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: Fixed in f82d2dd99. Preview now requires version to be a nonblank string, matching OssieDocumentConverter without imposing an additional version allowlist. The preview displays the OSSIE version alongside the destination schema. Tests cover missing, null, numeric, object/array, empty, and whitespace-only versions, and the browser check confirms invalid files cannot be submitted while valid versions appear in the preview. -- 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]
