This is an automated email from the ASF dual-hosted git repository. beto pushed a commit to branch semantic-layer-ui-semantic-layer in repository https://gitbox.apache.org/repos/asf/superset.git
commit 52bf18b12a852f1428d5afd015eafe4df8a79907 Author: Beto Dealmeida <[email protected]> AuthorDate: Fri Feb 13 14:47:50 2026 -0500 Snwoflake form --- superset-frontend/package.json | 7 + .../features/semanticLayers/SemanticLayerModal.tsx | 341 +++++++++++++++++++++ superset-frontend/src/pages/DatabaseList/index.tsx | 11 +- superset/semantic_layers/api.py | 7 +- 4 files changed, 363 insertions(+), 3 deletions(-) diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 48e037de5b0..d8e1c90cd72 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -116,7 +116,14 @@ "@luma.gl/gltf": "~9.2.5", "@luma.gl/shadertools": "~9.2.5", "@luma.gl/webgl": "~9.2.5", + "@fontsource/fira-code": "^5.2.7", + "@fontsource/inter": "^5.2.8", + "@great-expectations/jsonforms-antd-renderers": "^2.2.10", + "@jsonforms/core": "^3.7.0", + "@jsonforms/react": "^3.7.0", + "@jsonforms/vanilla-renderers": "^3.7.0", "@reduxjs/toolkit": "^1.9.3", + "@rjsf/antd": "^5.24.13", "@rjsf/core": "^5.24.13", "@rjsf/utils": "^5.24.3", "@rjsf/validator-ajv8": "^5.24.13", diff --git a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx new file mode 100644 index 00000000000..0e176fa081b --- /dev/null +++ b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx @@ -0,0 +1,341 @@ +/** + * 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, useEffect, useCallback, useRef } from 'react'; +import { t } from '@apache-superset/core'; +import { styled } from '@apache-superset/core/ui'; +import { SupersetClient } from '@superset-ui/core'; +import { Select } from '@superset-ui/core/components'; +import { Icons } from '@superset-ui/core/components/Icons'; +import { JsonForms } from '@jsonforms/react'; +import type { JsonSchema, UISchemaElement } from '@jsonforms/core'; +import { + rendererRegistryEntries, + cellRegistryEntries, +} from '@great-expectations/jsonforms-antd-renderers'; +import type { ErrorObject } from 'ajv'; +import { + StandardModal, + ModalFormField, + MODAL_STANDARD_WIDTH, + MODAL_MEDIUM_WIDTH, +} from 'src/components/Modal'; + +type Step = 'type' | 'config'; +type ValidationMode = 'ValidateAndHide' | 'ValidateAndShow'; + +/** + * Removes empty `enum` arrays from schema properties. The JSON Schema spec + * requires `enum` to have at least one item, and AJV rejects empty arrays. + * Fields with empty enums are rendered as plain text inputs instead. + */ +function sanitizeSchema(schema: JsonSchema): JsonSchema { + if (!schema.properties) return schema; + const properties: Record<string, JsonSchema> = {}; + for (const [key, prop] of Object.entries(schema.properties)) { + if ( + typeof prop === 'object' && + prop !== null && + 'enum' in prop && + Array.isArray(prop.enum) && + prop.enum.length === 0 + ) { + const { enum: _empty, ...rest } = prop; + properties[key] = rest; + } else { + properties[key] = prop as JsonSchema; + } + } + return { ...schema, properties }; +} + +/** + * Builds a JSON Forms UI schema from a JSON Schema, using the first + * `examples` entry as placeholder text for each string property. + */ +function buildUiSchema( + schema: JsonSchema, +): UISchemaElement | undefined { + if (!schema.properties) return undefined; + + // Use explicit property order from backend if available, + // otherwise fall back to the JSON object key order + const propertyOrder: string[] = + (schema as Record<string, unknown>)['x-propertyOrder'] as string[] ?? + Object.keys(schema.properties); + + const elements = propertyOrder + .filter(key => key in (schema.properties ?? {})) + .map(key => { + const prop = schema.properties![key]; + const control: Record<string, unknown> = { + type: 'Control', + scope: `#/properties/${key}`, + }; + if (typeof prop === 'object' && prop !== null) { + const options: Record<string, unknown> = {}; + if ( + 'examples' in prop && + Array.isArray(prop.examples) && + prop.examples.length > 0 + ) { + options.placeholderText = String(prop.examples[0]); + } + if ('description' in prop && typeof prop.description === 'string') { + options.tooltip = prop.description; + } + if (Object.keys(options).length > 0) { + control.options = options; + } + } + return control; + }); + return { type: 'VerticalLayout', elements } as UISchemaElement; +} + +const ModalContent = styled.div` + padding: ${({ theme }) => theme.sizeUnit * 4}px; +`; + +const BackLink = styled.button` + background: none; + border: none; + color: ${({ theme }) => theme.colorPrimary}; + cursor: pointer; + padding: 0; + font-size: ${({ theme }) => theme.fontSize[1]}px; + margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px; + display: inline-flex; + align-items: center; + gap: ${({ theme }) => theme.sizeUnit}px; + + &:hover { + text-decoration: underline; + } +`; + +interface SemanticLayerType { + id: string; + name: string; + description: string; +} + +interface SemanticLayerModalProps { + show: boolean; + onHide: () => void; + addDangerToast: (msg: string) => void; + addSuccessToast: (msg: string) => void; +} + +export default function SemanticLayerModal({ + show, + onHide, + addDangerToast, + addSuccessToast, +}: SemanticLayerModalProps) { + const [step, setStep] = useState<Step>('type'); + const [selectedType, setSelectedType] = useState<string | null>(null); + const [types, setTypes] = useState<SemanticLayerType[]>([]); + const [loading, setLoading] = useState(false); + const [configSchema, setConfigSchema] = useState<JsonSchema | null>(null); + const [uiSchema, setUiSchema] = useState<UISchemaElement | undefined>( + undefined, + ); + const [formData, setFormData] = useState<Record<string, unknown>>({}); + const [saving, setSaving] = useState(false); + const [validationMode, setValidationMode] = + useState<ValidationMode>('ValidateAndHide'); + const errorsRef = useRef<ErrorObject[]>([]); + + const fetchTypes = useCallback(async () => { + setLoading(true); + try { + const { json } = await SupersetClient.get({ + endpoint: '/api/v1/semantic_layer/types', + }); + setTypes(json.result ?? []); + } catch { + addDangerToast( + t('An error occurred while fetching semantic layer types'), + ); + } finally { + setLoading(false); + } + }, [addDangerToast]); + + const fetchConfigSchema = useCallback( + async (type: string) => { + setLoading(true); + try { + const { json } = await SupersetClient.post({ + endpoint: '/api/v1/semantic_layer/schema/configuration', + jsonPayload: { type }, + }); + const schema: JsonSchema = sanitizeSchema(json.result); + setConfigSchema(schema); + setUiSchema(buildUiSchema(schema)); + setStep('config'); + } catch { + addDangerToast( + t('An error occurred while fetching the configuration schema'), + ); + } finally { + setLoading(false); + } + }, + [addDangerToast], + ); + + useEffect(() => { + if (show) { + fetchTypes(); + } else { + setStep('type'); + setSelectedType(null); + setTypes([]); + setConfigSchema(null); + setUiSchema(undefined); + setFormData({}); + setValidationMode('ValidateAndHide'); + errorsRef.current = []; + } + }, [show, fetchTypes]); + + const handleStepAdvance = () => { + if (selectedType) { + fetchConfigSchema(selectedType); + } + }; + + const handleBack = () => { + setStep('type'); + setConfigSchema(null); + setUiSchema(undefined); + setFormData({}); + setValidationMode('ValidateAndHide'); + errorsRef.current = []; + }; + + const handleCreate = async () => { + setSaving(true); + try { + await SupersetClient.post({ + endpoint: '/api/v1/semantic_layer/', + jsonPayload: { type: selectedType, configuration: formData }, + }); + addSuccessToast(t('Semantic layer created')); + onHide(); + } catch { + addDangerToast(t('An error occurred while creating the semantic layer')); + } finally { + setSaving(false); + } + }; + + const handleSave = () => { + if (step === 'type') { + handleStepAdvance(); + } else { + setValidationMode('ValidateAndShow'); + if (errorsRef.current.length === 0) { + handleCreate(); + } + } + }; + + const handleFormChange = ({ + data, + errors, + }: { + data: Record<string, unknown>; + errors?: ErrorObject[]; + }) => { + setFormData(data); + errorsRef.current = errors ?? []; + if ( + validationMode === 'ValidateAndShow' && + errorsRef.current.length === 0 + ) { + handleCreate(); + } + }; + + const selectedTypeName = + types.find(type => type.id === selectedType)?.name ?? ''; + + const title = + step === 'type' + ? t('New Semantic Layer') + : t('Configure %s', selectedTypeName); + + return ( + <StandardModal + show={show} + onHide={onHide} + onSave={handleSave} + title={title} + icon={<Icons.PlusOutlined />} + width={step === 'type' ? MODAL_STANDARD_WIDTH : MODAL_MEDIUM_WIDTH} + saveDisabled={step === 'type' ? !selectedType : saving} + saveText={step === 'type' ? undefined : t('Create')} + saveLoading={saving} + contentLoading={loading} + > + {step === 'type' ? ( + <ModalContent> + <ModalFormField label={t('Type')}> + <Select + ariaLabel={t('Semantic layer type')} + placeholder={t('Select a semantic layer type')} + value={selectedType} + onChange={value => setSelectedType(value as string)} + options={types.map(type => ({ + value: type.id, + label: type.name, + }))} + getPopupContainer={() => document.body} + dropdownAlign={{ + points: ['tl', 'bl'], + offset: [0, 4], + overflow: { adjustX: 0, adjustY: 1 }, + }} + /> + </ModalFormField> + </ModalContent> + ) : ( + <ModalContent> + <BackLink type="button" onClick={handleBack}> + <Icons.CaretLeftOutlined iconSize="s" /> + {t('Back')} + </BackLink> + {configSchema && ( + <JsonForms + schema={configSchema} + uischema={uiSchema} + data={formData} + renderers={rendererRegistryEntries} + cells={cellRegistryEntries} + validationMode={validationMode} + onChange={handleFormChange} + /> + )} + </ModalContent> + )} + </StandardModal> + ); +} diff --git a/superset-frontend/src/pages/DatabaseList/index.tsx b/superset-frontend/src/pages/DatabaseList/index.tsx index bf0d2206d54..c45e3d3f3d2 100644 --- a/superset-frontend/src/pages/DatabaseList/index.tsx +++ b/superset-frontend/src/pages/DatabaseList/index.tsx @@ -62,6 +62,7 @@ import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; import type { MenuObjectProps } from 'src/types/bootstrapTypes'; import DatabaseModal from 'src/features/databases/DatabaseModal'; import UploadDataModal from 'src/features/databases/UploadDataModel'; +import SemanticLayerModal from 'src/features/semanticLayers/SemanticLayerModal'; import { DatabaseObject } from 'src/features/databases/types'; import { QueryObjectColumns } from 'src/views/CRUD/types'; import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils'; @@ -156,6 +157,8 @@ function DatabaseList({ useState<boolean>(false); const [columnarUploadDataModalOpen, setColumnarUploadDataModalOpen] = useState<boolean>(false); + const [semanticLayerModalOpen, setSemanticLayerModalOpen] = + useState<boolean>(false); const [allowUploads, setAllowUploads] = useState<boolean>(false); const isAdmin = isUserAdmin(fullUser); @@ -349,7 +352,7 @@ function DatabaseList({ key: 'semantic-layer', label: t('Semantic Layer'), onClick: () => { - // TODO: open semantic layer creation flow + setSemanticLayerModalOpen(true); }, }, ], @@ -756,6 +759,12 @@ function DatabaseList({ allowedExtensions={COLUMNAR_EXTENSIONS} type="columnar" /> + <SemanticLayerModal + show={semanticLayerModalOpen} + onHide={() => setSemanticLayerModalOpen(false)} + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} + /> {databaseCurrentlyDeleting && ( <DeleteModal description={ diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py index f07054d86f6..a0a87e55081 100644 --- a/superset/semantic_layers/api.py +++ b/superset/semantic_layers/api.py @@ -16,10 +16,11 @@ # under the License. from __future__ import annotations +import json import logging from typing import Any -from flask import request, Response +from flask import make_response, request, Response from flask_appbuilder.api import expose, protect, safe from flask_appbuilder.models.sqla.interface import SQLAInterface from marshmallow import ValidationError @@ -229,7 +230,9 @@ class SemanticLayerRestApi(BaseSupersetApi): parsed_config = None schema = cls.get_configuration_schema(parsed_config) - return self.response(200, result=schema) + resp = make_response(json.dumps({"result": schema}, sort_keys=False), 200) + resp.headers["Content-Type"] = "application/json; charset=utf-8" + return resp @expose("/<uuid>/schema/runtime", methods=("POST",)) @protect()
