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()

Reply via email to