This is an automated email from the ASF dual-hosted git repository.

beto pushed a commit to branch semantic-layer-add-semantic-view
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 31bc8f86e310214d4236e9d82c6ac5ef020632a6
Author: Beto Dealmeida <[email protected]>
AuthorDate: Tue Feb 17 11:50:06 2026 -0500

    feat: CRUD for adding/deleting semantic views
---
 .../features/semanticLayers/SemanticLayerModal.tsx | 253 +----------
 .../features/semanticLayers/jsonFormsHelpers.tsx   | 257 +++++++++++
 .../semanticViews/AddSemanticViewModal.tsx         | 486 +++++++++++++++++++++
 superset-frontend/src/pages/DatasetList/index.tsx  | 178 ++++++--
 superset/commands/semantic_layer/create.py         |  29 +-
 superset/commands/semantic_layer/delete.py         |  29 +-
 superset/commands/semantic_layer/exceptions.py     |   8 +
 superset/daos/semantic_layer.py                    |  25 +-
 superset/semantic_layers/api.py                    | 199 ++++++++-
 superset/semantic_layers/schemas.py                |   8 +
 superset/static/service-worker.js                  |   2 +-
 11 files changed, 1196 insertions(+), 278 deletions(-)

diff --git 
a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx 
b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx
index 37694a9e9a6..25bf810ca48 100644
--- a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx
+++ b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx
@@ -20,27 +20,12 @@ 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 { Input, Spin } from 'antd';
+import { Input } from 'antd';
 import { Select } from '@superset-ui/core/components';
 import { Icons } from '@superset-ui/core/components/Icons';
-import { JsonForms, withJsonFormsControlProps } from '@jsonforms/react';
-import type {
-  JsonSchema,
-  UISchemaElement,
-  ControlProps,
-} from '@jsonforms/core';
-import {
-  rankWith,
-  and,
-  isStringControl,
-  formatIs,
-  schemaMatches,
-} from '@jsonforms/core';
-import {
-  rendererRegistryEntries,
-  cellRegistryEntries,
-  TextControl,
-} from '@great-expectations/jsonforms-antd-renderers';
+import { JsonForms } from '@jsonforms/react';
+import type { JsonSchema, UISchemaElement } from '@jsonforms/core';
+import { cellRegistryEntries } from 
'@great-expectations/jsonforms-antd-renderers';
 import type { ErrorObject } from 'ajv';
 import {
   StandardModal,
@@ -48,229 +33,19 @@ import {
   MODAL_STANDARD_WIDTH,
   MODAL_MEDIUM_WIDTH,
 } from 'src/components/Modal';
-
-/**
- * Custom renderer that renders `Input.Password` for fields with
- * `format: "password"` in the JSON Schema (e.g. Pydantic `SecretStr`).
- */
-function PasswordControl(props: ControlProps) {
-  const uischema = {
-    ...props.uischema,
-    options: { ...props.uischema.options, type: 'password' },
-  };
-  return TextControl({ ...props, uischema });
-}
-const PasswordRenderer = withJsonFormsControlProps(PasswordControl);
-const passwordEntry = {
-  tester: rankWith(3, and(isStringControl, formatIs('password'))),
-  renderer: PasswordRenderer,
-};
-
-/**
- * Renderer for `const` properties (e.g. Pydantic discriminator fields).
- * Renders nothing visually but ensures the const value is set in form data,
- * so discriminated unions resolve correctly on the backend.
- */
-function ConstControl({ data, handleChange, path, schema }: ControlProps) {
-  const constValue = (schema as Record<string, unknown>).const;
-  useEffect(() => {
-    if (constValue !== undefined && data !== constValue) {
-      handleChange(path, constValue);
-    }
-  }, [constValue, data, handleChange, path]);
-  return null;
-}
-const ConstRenderer = withJsonFormsControlProps(ConstControl);
-const constEntry = {
-  tester: rankWith(10, schemaMatches(s => s !== undefined && 'const' in s)),
-  renderer: ConstRenderer,
-};
-
-/**
- * Renderer for fields marked `x-dynamic` in the JSON Schema.
- * Shows a loading spinner inside the input while the schema is being
- * refreshed with dynamic values from the backend.
- */
-function DynamicFieldControl(props: ControlProps) {
-  const { refreshingSchema, formData: cfgData } = props.config ?? {};
-  const deps = (props.schema as Record<string, unknown>)?.['x-dependsOn'];
-  const refreshing =
-    refreshingSchema &&
-    Array.isArray(deps) &&
-    areDependenciesSatisfied(deps as string[], (cfgData as Record<string, 
unknown>) ?? {});
-
-  if (!refreshing) {
-    return TextControl(props);
-  }
-
-  const uischema = {
-    ...props.uischema,
-    options: {
-      ...props.uischema.options,
-      placeholderText: t('Loading...'),
-      inputProps: { suffix: <Spin size="small" /> },
-    },
-  };
-  return TextControl({ ...props, uischema, enabled: false });
-}
-const DynamicFieldRenderer = withJsonFormsControlProps(DynamicFieldControl);
-const dynamicFieldEntry = {
-  tester: rankWith(
-    3,
-    and(
-      isStringControl,
-      schemaMatches(
-        s => (s as Record<string, unknown>)?.['x-dynamic'] === true,
-      ),
-    ),
-  ),
-  renderer: DynamicFieldRenderer,
-};
-
-const renderers = [
-  ...rendererRegistryEntries,
-  passwordEntry,
-  constEntry,
-  dynamicFieldEntry,
-];
+import {
+  renderers,
+  sanitizeSchema,
+  buildUiSchema,
+  getDynamicDependencies,
+  areDependenciesSatisfied,
+  serializeDependencyValues,
+  SCHEMA_REFRESH_DEBOUNCE_MS,
+} from './jsonFormsHelpers';
 
 type Step = 'type' | 'config';
 type ValidationMode = 'ValidateAndHide' | 'ValidateAndShow';
 
-const SCHEMA_REFRESH_DEBOUNCE_MS = 500;
-
-/**
- * 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;
-}
-
-/**
- * Extracts dynamic field dependency mappings from the schema.
- * Returns a map of field name → list of dependency field names.
- */
-function getDynamicDependencies(
-  schema: JsonSchema,
-): Record<string, string[]> {
-  const deps: Record<string, string[]> = {};
-  if (!schema.properties) return deps;
-  for (const [key, prop] of Object.entries(schema.properties)) {
-    if (
-      typeof prop === 'object' &&
-      prop !== null &&
-      'x-dynamic' in prop &&
-      'x-dependsOn' in prop &&
-      Array.isArray((prop as Record<string, unknown>)['x-dependsOn'])
-    ) {
-      deps[key] = (prop as Record<string, unknown>)[
-        'x-dependsOn'
-      ] as string[];
-    }
-  }
-  return deps;
-}
-
-/**
- * Checks whether all dependency values are filled (non-empty).
- * Handles nested objects (like auth) by checking they have at least one key.
- */
-function areDependenciesSatisfied(
-  dependencies: string[],
-  data: Record<string, unknown>,
-): boolean {
-  return dependencies.every(dep => {
-    const value = data[dep];
-    if (value === null || value === undefined || value === '') return false;
-    if (typeof value === 'object' && Object.keys(value).length === 0)
-      return false;
-    return true;
-  });
-}
-
-/**
- * Serializes the dependency values for a set of fields into a stable string
- * for comparison, so we only re-fetch when dependency values actually change.
- */
-function serializeDependencyValues(
-  dynamicDeps: Record<string, string[]>,
-  data: Record<string, unknown>,
-): string {
-  const allDepKeys = new Set<string>();
-  for (const deps of Object.values(dynamicDeps)) {
-    for (const dep of deps) {
-      allDepKeys.add(dep);
-    }
-  }
-  const snapshot: Record<string, unknown> = {};
-  for (const key of [...allDepKeys].sort()) {
-    snapshot[key] = data[key];
-  }
-  return JSON.stringify(snapshot);
-}
-
 const ModalContent = styled.div`
   padding: ${({ theme }) => theme.sizeUnit * 4}px;
 `;
@@ -395,7 +170,7 @@ export default function SemanticLayerModal({
         setSelectedType(layer.type);
         setFormData(layer.configuration ?? {});
         setHasErrors(false);
-        // Fetch base schema (no configuration → no Snowflake connection) to
+        // Fetch base schema (no configuration -> no Snowflake connection) to
         // show the form immediately. The existing maybeRefreshSchema machinery
         // will trigger an enriched fetch in the background once deps are
         // satisfied, and DynamicFieldControl will show per-field spinners.
diff --git a/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx 
b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
new file mode 100644
index 00000000000..6b21e73e9ca
--- /dev/null
+++ b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
@@ -0,0 +1,257 @@
+/**
+ * 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 } from 'react';
+import { t } from '@apache-superset/core';
+import { Spin } from 'antd';
+import { withJsonFormsControlProps } from '@jsonforms/react';
+import type {
+  JsonSchema,
+  UISchemaElement,
+  ControlProps,
+} from '@jsonforms/core';
+import {
+  rankWith,
+  and,
+  isStringControl,
+  formatIs,
+  schemaMatches,
+} from '@jsonforms/core';
+import {
+  rendererRegistryEntries,
+  TextControl,
+} from '@great-expectations/jsonforms-antd-renderers';
+
+export const SCHEMA_REFRESH_DEBOUNCE_MS = 500;
+
+/**
+ * Custom renderer that renders `Input.Password` for fields with
+ * `format: "password"` in the JSON Schema (e.g. Pydantic `SecretStr`).
+ */
+function PasswordControl(props: ControlProps) {
+  const uischema = {
+    ...props.uischema,
+    options: { ...props.uischema.options, type: 'password' },
+  };
+  return TextControl({ ...props, uischema });
+}
+const PasswordRenderer = withJsonFormsControlProps(PasswordControl);
+const passwordEntry = {
+  tester: rankWith(3, and(isStringControl, formatIs('password'))),
+  renderer: PasswordRenderer,
+};
+
+/**
+ * Renderer for `const` properties (e.g. Pydantic discriminator fields).
+ * Renders nothing visually but ensures the const value is set in form data,
+ * so discriminated unions resolve correctly on the backend.
+ */
+function ConstControl({ data, handleChange, path, schema }: ControlProps) {
+  const constValue = (schema as Record<string, unknown>).const;
+  useEffect(() => {
+    if (constValue !== undefined && data !== constValue) {
+      handleChange(path, constValue);
+    }
+  }, [constValue, data, handleChange, path]);
+  return null;
+}
+const ConstRenderer = withJsonFormsControlProps(ConstControl);
+const constEntry = {
+  tester: rankWith(10, schemaMatches(s => s !== undefined && 'const' in s)),
+  renderer: ConstRenderer,
+};
+
+/**
+ * Renderer for fields marked `x-dynamic` in the JSON Schema.
+ * Shows a loading spinner inside the input while the schema is being
+ * refreshed with dynamic values from the backend.
+ */
+function DynamicFieldControl(props: ControlProps) {
+  const { refreshingSchema, formData: cfgData } = props.config ?? {};
+  const deps = (props.schema as Record<string, unknown>)?.['x-dependsOn'];
+  const refreshing =
+    refreshingSchema &&
+    Array.isArray(deps) &&
+    areDependenciesSatisfied(deps as string[], (cfgData as Record<string, 
unknown>) ?? {});
+
+  if (!refreshing) {
+    return TextControl(props);
+  }
+
+  const uischema = {
+    ...props.uischema,
+    options: {
+      ...props.uischema.options,
+      placeholderText: t('Loading...'),
+      inputProps: { suffix: <Spin size="small" /> },
+    },
+  };
+  return TextControl({ ...props, uischema, enabled: false });
+}
+const DynamicFieldRenderer = withJsonFormsControlProps(DynamicFieldControl);
+const dynamicFieldEntry = {
+  tester: rankWith(
+    3,
+    and(
+      isStringControl,
+      schemaMatches(
+        s => (s as Record<string, unknown>)?.['x-dynamic'] === true,
+      ),
+    ),
+  ),
+  renderer: DynamicFieldRenderer,
+};
+
+export const renderers = [
+  ...rendererRegistryEntries,
+  passwordEntry,
+  constEntry,
+  dynamicFieldEntry,
+];
+
+/**
+ * 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.
+ */
+export 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.
+ */
+export 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;
+}
+
+/**
+ * Extracts dynamic field dependency mappings from the schema.
+ * Returns a map of field name -> list of dependency field names.
+ */
+export function getDynamicDependencies(
+  schema: JsonSchema,
+): Record<string, string[]> {
+  const deps: Record<string, string[]> = {};
+  if (!schema.properties) return deps;
+  for (const [key, prop] of Object.entries(schema.properties)) {
+    if (
+      typeof prop === 'object' &&
+      prop !== null &&
+      'x-dynamic' in prop &&
+      'x-dependsOn' in prop &&
+      Array.isArray((prop as Record<string, unknown>)['x-dependsOn'])
+    ) {
+      deps[key] = (prop as Record<string, unknown>)[
+        'x-dependsOn'
+      ] as string[];
+    }
+  }
+  return deps;
+}
+
+/**
+ * Checks whether all dependency values are filled (non-empty).
+ * Handles nested objects (like auth) by checking they have at least one key.
+ */
+export function areDependenciesSatisfied(
+  dependencies: string[],
+  data: Record<string, unknown>,
+): boolean {
+  return dependencies.every(dep => {
+    const value = data[dep];
+    if (value === null || value === undefined || value === '') return false;
+    if (typeof value === 'object' && Object.keys(value).length === 0)
+      return false;
+    return true;
+  });
+}
+
+/**
+ * Serializes the dependency values for a set of fields into a stable string
+ * for comparison, so we only re-fetch when dependency values actually change.
+ */
+export function serializeDependencyValues(
+  dynamicDeps: Record<string, string[]>,
+  data: Record<string, unknown>,
+): string {
+  const allDepKeys = new Set<string>();
+  for (const deps of Object.values(dynamicDeps)) {
+    for (const dep of deps) {
+      allDepKeys.add(dep);
+    }
+  }
+  const snapshot: Record<string, unknown> = {};
+  for (const key of [...allDepKeys].sort()) {
+    snapshot[key] = data[key];
+  }
+  return JSON.stringify(snapshot);
+}
diff --git 
a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx 
b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
new file mode 100644
index 00000000000..6859dcab8eb
--- /dev/null
+++ b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
@@ -0,0 +1,486 @@
+/**
+ * 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 { Checkbox, Spin } from 'antd';
+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 { 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';
+import {
+  renderers,
+  sanitizeSchema,
+  buildUiSchema,
+  getDynamicDependencies,
+  areDependenciesSatisfied,
+  serializeDependencyValues,
+  SCHEMA_REFRESH_DEBOUNCE_MS,
+} from 'src/features/semanticLayers/jsonFormsHelpers';
+
+type Step = 'layer' | 'configure' | 'select';
+
+interface SemanticLayerOption {
+  uuid: string;
+  name: string;
+}
+
+interface AvailableView {
+  name: string;
+  already_added: boolean;
+}
+
+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;
+  }
+`;
+
+const ViewList = styled.div`
+  display: flex;
+  flex-direction: column;
+  gap: ${({ theme }) => theme.sizeUnit}px;
+`;
+
+const LoadingContainer = styled.div`
+  display: flex;
+  justify-content: center;
+  padding: ${({ theme }) => theme.sizeUnit * 6}px;
+`;
+
+interface AddSemanticViewModalProps {
+  show: boolean;
+  onHide: () => void;
+  onSuccess: () => void;
+  addDangerToast: (msg: string) => void;
+  addSuccessToast: (msg: string) => void;
+}
+
+export default function AddSemanticViewModal({
+  show,
+  onHide,
+  onSuccess,
+  addDangerToast,
+  addSuccessToast,
+}: AddSemanticViewModalProps) {
+  const [step, setStep] = useState<Step>('layer');
+  const [layers, setLayers] = useState<SemanticLayerOption[]>([]);
+  const [selectedLayerUuid, setSelectedLayerUuid] = useState<string | null>(
+    null,
+  );
+  const [loading, setLoading] = useState(false);
+  const [saving, setSaving] = useState(false);
+
+  // Step 2: Configure (runtime schema)
+  const [runtimeSchema, setRuntimeSchema] = useState<JsonSchema | null>(null);
+  const [runtimeUiSchema, setRuntimeUiSchema] = useState<
+    UISchemaElement | undefined
+  >(undefined);
+  const [runtimeData, setRuntimeData] = useState<Record<string, unknown>>({});
+  const [refreshingSchema, setRefreshingSchema] = useState(false);
+  const [hasRuntimeErrors, setHasRuntimeErrors] = useState(false);
+  const errorsRef = useRef<ErrorObject[]>([]);
+  const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const lastDepSnapshotRef = useRef<string>('');
+  const dynamicDepsRef = useRef<Record<string, string[]>>({});
+
+  // Step 3: Select views
+  const [availableViews, setAvailableViews] = useState<AvailableView[]>([]);
+  const [selectedViews, setSelectedViews] = useState<Set<string>>(new Set());
+  const [loadingViews, setLoadingViews] = useState(false);
+
+  // Reset state when modal closes
+  useEffect(() => {
+    if (show) {
+      fetchLayers();
+    } else {
+      setStep('layer');
+      setLayers([]);
+      setSelectedLayerUuid(null);
+      setLoading(false);
+      setSaving(false);
+      setRuntimeSchema(null);
+      setRuntimeUiSchema(undefined);
+      setRuntimeData({});
+      setRefreshingSchema(false);
+      setHasRuntimeErrors(false);
+      errorsRef.current = [];
+      lastDepSnapshotRef.current = '';
+      dynamicDepsRef.current = {};
+      setAvailableViews([]);
+      setSelectedViews(new Set());
+      setLoadingViews(false);
+      if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
+    }
+  }, [show]); // eslint-disable-line react-hooks/exhaustive-deps
+
+  const fetchLayers = async () => {
+    setLoading(true);
+    try {
+      const { json } = await SupersetClient.get({
+        endpoint: '/api/v1/semantic_layer/',
+      });
+      setLayers(
+        (json.result ?? []).map((l: { uuid: string; name: string }) => ({
+          uuid: l.uuid,
+          name: l.name,
+        })),
+      );
+    } catch {
+      addDangerToast(t('An error occurred while fetching semantic layers'));
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const applyRuntimeSchema = useCallback((rawSchema: JsonSchema) => {
+    const schema = sanitizeSchema(rawSchema);
+    setRuntimeSchema(schema);
+    setRuntimeUiSchema(buildUiSchema(schema));
+    dynamicDepsRef.current = getDynamicDependencies(rawSchema);
+  }, []);
+
+  const fetchRuntimeSchema = useCallback(
+    async (
+      uuid: string,
+      currentRuntimeData?: Record<string, unknown>,
+    ) => {
+      const isInitialFetch = !currentRuntimeData;
+      if (isInitialFetch) setLoading(true);
+      else setRefreshingSchema(true);
+      try {
+        const { json } = await SupersetClient.post({
+          endpoint: `/api/v1/semantic_layer/${uuid}/schema/runtime`,
+          jsonPayload: currentRuntimeData
+            ? { runtime_data: currentRuntimeData }
+            : {},
+        });
+        const schema = json.result;
+        if (
+          isInitialFetch &&
+          (!schema ||
+            !schema.properties ||
+            Object.keys(schema.properties).length === 0)
+        ) {
+          // No runtime config needed — skip to step 3
+          fetchViews(uuid, {});
+        } else if (isInitialFetch) {
+          applyRuntimeSchema(schema);
+          setStep('configure');
+        } else {
+          applyRuntimeSchema(schema);
+        }
+      } catch {
+        if (isInitialFetch) {
+          addDangerToast(
+            t('An error occurred while fetching the runtime schema'),
+          );
+        }
+      } finally {
+        if (isInitialFetch) setLoading(false);
+        else setRefreshingSchema(false);
+      }
+    },
+    [addDangerToast, applyRuntimeSchema], // eslint-disable-line 
react-hooks/exhaustive-deps
+  );
+
+  const fetchViews = useCallback(
+    async (uuid: string, rData: Record<string, unknown>) => {
+      setLoadingViews(true);
+      setStep('select');
+      try {
+        const { json } = await SupersetClient.post({
+          endpoint: `/api/v1/semantic_layer/${uuid}/views`,
+          jsonPayload: { runtime_data: rData },
+        });
+        const views: AvailableView[] = json.result ?? [];
+        setAvailableViews(views);
+        // Pre-select views that are already added (disabled anyway)
+        setSelectedViews(
+          new Set(views.filter(v => v.already_added).map(v => v.name)),
+        );
+      } catch {
+        addDangerToast(t('An error occurred while fetching available views'));
+      } finally {
+        setLoadingViews(false);
+      }
+    },
+    [addDangerToast],
+  );
+
+  const maybeRefreshRuntimeSchema = useCallback(
+    (data: Record<string, unknown>) => {
+      if (!selectedLayerUuid) return;
+
+      const dynamicDeps = dynamicDepsRef.current;
+      if (Object.keys(dynamicDeps).length === 0) return;
+
+      const hasSatisfiedDeps = Object.values(dynamicDeps).some(deps =>
+        areDependenciesSatisfied(deps, data),
+      );
+      if (!hasSatisfiedDeps) return;
+
+      const snapshot = serializeDependencyValues(dynamicDeps, data);
+      if (snapshot === lastDepSnapshotRef.current) return;
+      lastDepSnapshotRef.current = snapshot;
+
+      if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
+      debounceTimerRef.current = setTimeout(() => {
+        fetchRuntimeSchema(selectedLayerUuid, data);
+      }, SCHEMA_REFRESH_DEBOUNCE_MS);
+    },
+    [selectedLayerUuid, fetchRuntimeSchema],
+  );
+
+  const handleRuntimeFormChange = useCallback(
+    ({
+      data,
+      errors,
+    }: {
+      data: Record<string, unknown>;
+      errors?: ErrorObject[];
+    }) => {
+      setRuntimeData(data);
+      errorsRef.current = errors ?? [];
+      setHasRuntimeErrors(errorsRef.current.length > 0);
+      maybeRefreshRuntimeSchema(data);
+    },
+    [maybeRefreshRuntimeSchema],
+  );
+
+  const handleToggleView = (viewName: string, checked: boolean) => {
+    setSelectedViews(prev => {
+      const next = new Set(prev);
+      if (checked) {
+        next.add(viewName);
+      } else {
+        next.delete(viewName);
+      }
+      return next;
+    });
+  };
+
+  const newViewCount = availableViews.filter(
+    v => selectedViews.has(v.name) && !v.already_added,
+  ).length;
+
+  const handleAddViews = async () => {
+    if (!selectedLayerUuid) return;
+    setSaving(true);
+    try {
+      const viewsToCreate = availableViews
+        .filter(v => selectedViews.has(v.name) && !v.already_added)
+        .map(v => ({
+          name: v.name,
+          semantic_layer_uuid: selectedLayerUuid,
+          configuration: runtimeData,
+        }));
+
+      await SupersetClient.post({
+        endpoint: '/api/v1/semantic_view/',
+        jsonPayload: { views: viewsToCreate },
+      });
+
+      addSuccessToast(
+        t('%s semantic view(s) added', viewsToCreate.length),
+      );
+      onSuccess();
+      onHide();
+    } catch {
+      addDangerToast(t('An error occurred while adding semantic views'));
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  const handleSave = () => {
+    if (step === 'layer' && selectedLayerUuid) {
+      fetchRuntimeSchema(selectedLayerUuid);
+    } else if (step === 'configure' && selectedLayerUuid) {
+      fetchViews(selectedLayerUuid, runtimeData);
+    } else if (step === 'select') {
+      handleAddViews();
+    }
+  };
+
+  const handleBack = () => {
+    if (step === 'configure') {
+      setStep('layer');
+      setRuntimeSchema(null);
+      setRuntimeUiSchema(undefined);
+      setRuntimeData({});
+      setHasRuntimeErrors(false);
+      errorsRef.current = [];
+      lastDepSnapshotRef.current = '';
+      dynamicDepsRef.current = {};
+      if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
+    } else if (step === 'select') {
+      // Go back to configure if we had a runtime schema, otherwise to layer
+      if (runtimeSchema) {
+        setStep('configure');
+      } else {
+        setStep('layer');
+      }
+      setAvailableViews([]);
+      setSelectedViews(new Set());
+    }
+  };
+
+  const title =
+    step === 'layer'
+      ? t('Add Semantic View')
+      : step === 'configure'
+        ? t('Configure')
+        : t('Select Views');
+
+  const saveText =
+    step === 'select'
+      ? t('Add %s view(s)', newViewCount)
+      : t('Next');
+
+  const saveDisabled =
+    step === 'layer'
+      ? !selectedLayerUuid
+      : step === 'configure'
+        ? hasRuntimeErrors
+        : step === 'select'
+          ? newViewCount === 0 || saving
+          : false;
+
+  const modalWidth =
+    step === 'configure' ? MODAL_MEDIUM_WIDTH : MODAL_STANDARD_WIDTH;
+
+  return (
+    <StandardModal
+      show={show}
+      onHide={onHide}
+      onSave={handleSave}
+      title={title}
+      icon={<Icons.PlusOutlined />}
+      width={modalWidth}
+      saveDisabled={saveDisabled}
+      saveText={saveText}
+      saveLoading={saving}
+      contentLoading={loading}
+    >
+      {step === 'layer' && (
+        <ModalContent>
+          <ModalFormField label={t('Semantic Layer')}>
+            <Select
+              ariaLabel={t('Semantic layer')}
+              placeholder={t('Select a semantic layer')}
+              value={selectedLayerUuid}
+              onChange={value => setSelectedLayerUuid(value as string)}
+              options={layers.map(l => ({
+                value: l.uuid,
+                label: l.name,
+              }))}
+              getPopupContainer={() => document.body}
+              dropdownAlign={{
+                points: ['tl', 'bl'],
+                offset: [0, 4],
+                overflow: { adjustX: 0, adjustY: 1 },
+              }}
+            />
+          </ModalFormField>
+        </ModalContent>
+      )}
+
+      {step === 'configure' && (
+        <ModalContent>
+          <BackLink type="button" onClick={handleBack}>
+            <Icons.CaretLeftOutlined iconSize="s" />
+            {t('Back')}
+          </BackLink>
+          {runtimeSchema && (
+            <JsonForms
+              schema={runtimeSchema}
+              uischema={runtimeUiSchema}
+              data={runtimeData}
+              renderers={renderers}
+              cells={cellRegistryEntries}
+              config={{ refreshingSchema, formData: runtimeData }}
+              validationMode="ValidateAndHide"
+              onChange={handleRuntimeFormChange}
+            />
+          )}
+        </ModalContent>
+      )}
+
+      {step === 'select' && (
+        <ModalContent>
+          <BackLink type="button" onClick={handleBack}>
+            <Icons.CaretLeftOutlined iconSize="s" />
+            {t('Back')}
+          </BackLink>
+          {loadingViews ? (
+            <LoadingContainer>
+              <Spin />
+            </LoadingContainer>
+          ) : (
+            <ViewList>
+              {availableViews.map(view => (
+                <Checkbox
+                  key={view.name}
+                  checked={selectedViews.has(view.name)}
+                  disabled={view.already_added}
+                  onChange={e =>
+                    handleToggleView(view.name, e.target.checked)
+                  }
+                >
+                  {view.name}
+                  {view.already_added && (
+                    <span> ({t('Already added')})</span>
+                  )}
+                </Checkbox>
+              ))}
+              {availableViews.length === 0 && !loadingViews && (
+                <span>{t('No views available')}</span>
+              )}
+            </ViewList>
+          )}
+        </ModalContent>
+      )}
+    </StandardModal>
+  );
+}
diff --git a/superset-frontend/src/pages/DatasetList/index.tsx 
b/superset-frontend/src/pages/DatasetList/index.tsx
index ceafdd03a42..65cb31f74a7 100644
--- a/superset-frontend/src/pages/DatasetList/index.tsx
+++ b/superset-frontend/src/pages/DatasetList/index.tsx
@@ -35,9 +35,11 @@ import {
 import { ColumnObject } from 'src/features/datasets/types';
 import { useListViewResource } from 'src/views/CRUD/hooks';
 import {
+  Button,
   ConfirmStatusChange,
   CertifiedBadge,
   DeleteModal,
+  Dropdown,
   Tooltip,
   InfoTooltip,
   DatasetTypeLabel,
@@ -72,6 +74,7 @@ import {
 } from 'src/features/datasets/constants';
 import DuplicateDatasetModal from 
'src/features/datasets/DuplicateDatasetModal';
 import SemanticViewEditModal from 
'src/features/semanticViews/SemanticViewEditModal';
+import AddSemanticViewModal from 
'src/features/semanticViews/AddSemanticViewModal';
 import { useSelector } from 'react-redux';
 import { QueryObjectColumns } from 'src/views/CRUD/types';
 import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
@@ -262,6 +265,8 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
     null,
   );
 
+  const [showAddSemanticViewModal, setShowAddSemanticViewModal] =
+    useState(false);
   const [importingDataset, showImportModal] = useState<boolean>(false);
   const [passwordFields, setPasswordFields] = useState<string[]>([]);
   const [preparingExport, setPreparingExport] = useState<boolean>(false);
@@ -520,25 +525,43 @@ const DatasetList: FunctionComponent<DatasetListProps> = 
({
         Cell: ({ row: { original } }: any) => {
           const isSemanticView = original.source_type === 'semantic_layer';
 
-          // Semantic view: only show edit button
+          // Semantic view: show edit and delete buttons
           if (isSemanticView) {
-            if (!canEdit) return null;
+            if (!canEdit && !canDelete) return null;
             return (
               <Actions className="actions">
-                <Tooltip
-                  id="edit-action-tooltip"
-                  title={t('Edit')}
-                  placement="bottom"
-                >
-                  <span
-                    role="button"
-                    tabIndex={0}
-                    className="action-button"
-                    onClick={() => setSvCurrentlyEditing(original)}
+                {canDelete && (
+                  <Tooltip
+                    id="delete-action-tooltip"
+                    title={t('Delete')}
+                    placement="bottom"
                   >
-                    <Icons.EditOutlined iconSize="l" />
-                  </span>
-                </Tooltip>
+                    <span
+                      role="button"
+                      tabIndex={0}
+                      className="action-button"
+                      onClick={() => handleSemanticViewDelete(original)}
+                    >
+                      <Icons.DeleteOutlined iconSize="l" />
+                    </span>
+                  </Tooltip>
+                )}
+                {canEdit && (
+                  <Tooltip
+                    id="edit-action-tooltip"
+                    title={t('Edit')}
+                    placement="bottom"
+                  >
+                    <span
+                      role="button"
+                      tabIndex={0}
+                      className="action-button"
+                      onClick={() => setSvCurrentlyEditing(original)}
+                    >
+                      <Icons.EditOutlined iconSize="l" />
+                    </span>
+                  </Tooltip>
+                )}
               </Actions>
             );
           }
@@ -834,14 +857,58 @@ const DatasetList: FunctionComponent<DatasetListProps> = 
({
   }
 
   if (canCreate) {
-    buttonArr.push({
-      icon: <Icons.PlusOutlined iconSize="m" />,
-      name: t('Dataset'),
-      onClick: () => {
-        history.push('/dataset/add/');
-      },
-      buttonStyle: 'primary',
-    });
+    if (isFeatureEnabled(FeatureFlag.SemanticLayers)) {
+      buttonArr.push({
+        name: t('New'),
+        buttonStyle: 'primary',
+        component: (
+          <Dropdown
+            css={css`
+              margin-left: ${theme.sizeUnit * 2}px;
+            `}
+            menu={{
+              items: [
+                {
+                  key: 'dataset',
+                  label: t('Dataset'),
+                  onClick: () => history.push('/dataset/add/'),
+                },
+                {
+                  key: 'semantic-view',
+                  label: t('Semantic View'),
+                  onClick: () => setShowAddSemanticViewModal(true),
+                },
+              ],
+            }}
+            trigger={['click']}
+          >
+            <Button
+              data-test="btn-create-new"
+              buttonStyle="primary"
+              icon={<Icons.PlusOutlined iconSize="m" />}
+            >
+              {t('New')}
+              <Icons.DownOutlined
+                iconSize="s"
+                css={css`
+                  margin-left: ${theme.sizeUnit * 1.5}px;
+                  margin-right: -${theme.sizeUnit * 2}px;
+                `}
+              />
+            </Button>
+          </Dropdown>
+        ),
+      });
+    } else {
+      buttonArr.push({
+        icon: <Icons.PlusOutlined iconSize="m" />,
+        name: t('Dataset'),
+        onClick: () => {
+          history.push('/dataset/add/');
+        },
+        buttonStyle: 'primary',
+      });
+    }
   }
 
   menuData.buttons = buttonArr;
@@ -875,15 +942,61 @@ const DatasetList: FunctionComponent<DatasetListProps> = 
({
     );
   };
 
-  const handleBulkDatasetDelete = (datasetsToDelete: Dataset[]) => {
+  const handleSemanticViewDelete = ({
+    id,
+    table_name: tableName,
+  }: Dataset) => {
     SupersetClient.delete({
-      endpoint: `/api/v1/dataset/?q=${rison.encode(
-        datasetsToDelete.map(({ id }) => id),
-      )}`,
+      endpoint: `/api/v1/semantic_view/${id}`,
     }).then(
-      ({ json = {} }) => {
+      () => {
+        refreshData();
+        addSuccessToast(t('Deleted: %s', tableName));
+      },
+      createErrorHandler(errMsg =>
+        addDangerToast(
+          t('There was an issue deleting %s: %s', tableName, errMsg),
+        ),
+      ),
+    );
+  };
+
+  const handleBulkDatasetDelete = (datasetsToDelete: Dataset[]) => {
+    const datasets = datasetsToDelete.filter(
+      d => d.source_type !== 'semantic_layer',
+    );
+    const semanticViews = datasetsToDelete.filter(
+      d => d.source_type === 'semantic_layer',
+    );
+
+    const promises: Promise<unknown>[] = [];
+
+    if (datasets.length) {
+      promises.push(
+        SupersetClient.delete({
+          endpoint: `/api/v1/dataset/?q=${rison.encode(
+            datasets.map(({ id }) => id),
+          )}`,
+        }),
+      );
+    }
+
+    if (semanticViews.length) {
+      promises.push(
+        ...semanticViews.map(sv =>
+          SupersetClient.delete({
+            endpoint: `/api/v1/semantic_view/${sv.id}`,
+          }),
+        ),
+      );
+    }
+
+    Promise.all(promises).then(
+      () => {
         refreshData();
-        addSuccessToast(json.message);
+        addSuccessToast(
+          t('Deleted %s item(s)', datasetsToDelete.length),
+        );
       },
       createErrorHandler(errMsg =>
         addDangerToast(
@@ -1055,6 +1168,13 @@ const DatasetList: FunctionComponent<DatasetListProps> = 
({
         addSuccessToast={addSuccessToast}
         semanticView={svCurrentlyEditing}
       />
+      <AddSemanticViewModal
+        show={showAddSemanticViewModal}
+        onHide={() => setShowAddSemanticViewModal(false)}
+        onSuccess={refreshData}
+        addDangerToast={addDangerToast}
+        addSuccessToast={addSuccessToast}
+      />
       <ConfirmStatusChange
         title={t('Please confirm')}
         description={t(
diff --git a/superset/commands/semantic_layer/create.py 
b/superset/commands/semantic_layer/create.py
index 250476d210f..888d6f36dfd 100644
--- a/superset/commands/semantic_layer/create.py
+++ b/superset/commands/semantic_layer/create.py
@@ -28,8 +28,10 @@ from superset.commands.base import BaseCommand
 from superset.commands.semantic_layer.exceptions import (
     SemanticLayerCreateFailedError,
     SemanticLayerInvalidError,
+    SemanticLayerNotFoundError,
+    SemanticViewCreateFailedError,
 )
-from superset.daos.semantic_layer import SemanticLayerDAO
+from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
 from superset.semantic_layers.registry import registry
 from superset.utils.decorators import on_error, transaction
 
@@ -67,3 +69,28 @@ class CreateSemanticLayerCommand(BaseCommand):
         # Validate configuration against the plugin
         cls = registry[sl_type]
         cls.from_configuration(self._properties["configuration"])
+
+
+class CreateSemanticViewCommand(BaseCommand):
+    def __init__(self, data: dict[str, Any]):
+        self._properties = data.copy()
+
+    @transaction(
+        on_error=partial(
+            on_error,
+            catches=(SQLAlchemyError, ValueError),
+            reraise=SemanticViewCreateFailedError,
+        )
+    )
+    def run(self) -> Model:
+        self.validate()
+        if isinstance(self._properties.get("configuration"), dict):
+            self._properties["configuration"] = json.dumps(
+                self._properties["configuration"]
+            )
+        return SemanticViewDAO.create(attributes=self._properties)
+
+    def validate(self) -> None:
+        layer_uuid = self._properties.get("semantic_layer_uuid")
+        if not SemanticLayerDAO.find_by_uuid(layer_uuid):
+            raise SemanticLayerNotFoundError()
diff --git a/superset/commands/semantic_layer/delete.py 
b/superset/commands/semantic_layer/delete.py
index 677126221a8..1e9088476bc 100644
--- a/superset/commands/semantic_layer/delete.py
+++ b/superset/commands/semantic_layer/delete.py
@@ -25,9 +25,11 @@ from superset.commands.base import BaseCommand
 from superset.commands.semantic_layer.exceptions import (
     SemanticLayerDeleteFailedError,
     SemanticLayerNotFoundError,
+    SemanticViewDeleteFailedError,
+    SemanticViewNotFoundError,
 )
-from superset.daos.semantic_layer import SemanticLayerDAO
-from superset.semantic_layers.models import SemanticLayer
+from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
+from superset.semantic_layers.models import SemanticLayer, SemanticView
 from superset.utils.decorators import on_error, transaction
 
 logger = logging.getLogger(__name__)
@@ -54,3 +56,26 @@ class DeleteSemanticLayerCommand(BaseCommand):
         self._model = SemanticLayerDAO.find_by_uuid(self._uuid)
         if not self._model:
             raise SemanticLayerNotFoundError()
+
+
+class DeleteSemanticViewCommand(BaseCommand):
+    def __init__(self, pk: int):
+        self._pk = pk
+        self._model: SemanticView | None = None
+
+    @transaction(
+        on_error=partial(
+            on_error,
+            catches=(SQLAlchemyError,),
+            reraise=SemanticViewDeleteFailedError,
+        )
+    )
+    def run(self) -> None:
+        self.validate()
+        assert self._model
+        SemanticViewDAO.delete([self._model])
+
+    def validate(self) -> None:
+        self._model = SemanticViewDAO.find_by_id(self._pk)
+        if not self._model:
+            raise SemanticViewNotFoundError()
diff --git a/superset/commands/semantic_layer/exceptions.py 
b/superset/commands/semantic_layer/exceptions.py
index 1b82a65b841..ec1a4ac838d 100644
--- a/superset/commands/semantic_layer/exceptions.py
+++ b/superset/commands/semantic_layer/exceptions.py
@@ -66,3 +66,11 @@ class SemanticLayerUpdateFailedError(UpdateFailedError):
 
 class SemanticLayerDeleteFailedError(DeleteFailedError):
     message = _("Semantic layer could not be deleted.")
+
+
+class SemanticViewCreateFailedError(CreateFailedError):
+    message = _("Semantic view could not be created.")
+
+
+class SemanticViewDeleteFailedError(DeleteFailedError):
+    message = _("Semantic view could not be deleted.")
diff --git a/superset/daos/semantic_layer.py b/superset/daos/semantic_layer.py
index 13ce777567a..0cf75dd6d45 100644
--- a/superset/daos/semantic_layer.py
+++ b/superset/daos/semantic_layer.py
@@ -19,6 +19,8 @@
 
 from __future__ import annotations
 
+import json
+
 from superset.daos.base import BaseDAO
 from superset.extensions import db
 from superset.semantic_layers.models import SemanticLayer, SemanticView
@@ -114,18 +116,37 @@ class SemanticViewDAO(BaseDAO[SemanticView]):
         )
 
     @staticmethod
-    def validate_uniqueness(name: str, layer_uuid: str) -> bool:
+    def validate_uniqueness(
+        name: str,
+        layer_uuid: str,
+        configuration: dict | None = None,
+    ) -> bool:
         """
         Validate that view name is unique within semantic layer.
 
+        When *configuration* is provided, uniqueness is scoped to the
+        ``(name, layer_uuid, configuration)`` triple so the same view name
+        can exist with different runtime configurations.
+
         :param name: View name
         :param layer_uuid: UUID of the semantic layer
-        :return: True if name is unique within layer, False otherwise
+        :param configuration: Optional configuration dict for scoped uniqueness
+        :return: True if name is unique within layer (and config), False 
otherwise
         """
         query = db.session.query(SemanticView).filter(
             SemanticView.name == name,
             SemanticView.semantic_layer_uuid == layer_uuid,
         )
+        if configuration is not None:
+            config_str = json.dumps(configuration, sort_keys=True)
+            # Compare serialized configuration
+            for view in query.all():
+                existing_config = view.configuration
+                if isinstance(existing_config, str):
+                    existing_config = json.loads(existing_config)
+                if json.dumps(existing_config or {}, sort_keys=True) == 
config_str:
+                    return False
+            return True
         return not db.session.query(query.exists()).scalar()
 
     @staticmethod
diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py
index bed9dc996a5..c93e8039df6 100644
--- a/superset/semantic_layers/api.py
+++ b/superset/semantic_layers/api.py
@@ -28,14 +28,22 @@ from marshmallow import ValidationError
 from pydantic import ValidationError as PydanticValidationError
 
 from superset import db, event_logger, is_feature_enabled
-from superset.commands.semantic_layer.create import CreateSemanticLayerCommand
-from superset.commands.semantic_layer.delete import DeleteSemanticLayerCommand
+from superset.commands.semantic_layer.create import (
+    CreateSemanticLayerCommand,
+    CreateSemanticViewCommand,
+)
+from superset.commands.semantic_layer.delete import (
+    DeleteSemanticLayerCommand,
+    DeleteSemanticViewCommand,
+)
 from superset.commands.semantic_layer.exceptions import (
     SemanticLayerCreateFailedError,
     SemanticLayerDeleteFailedError,
     SemanticLayerInvalidError,
     SemanticLayerNotFoundError,
     SemanticLayerUpdateFailedError,
+    SemanticViewCreateFailedError,
+    SemanticViewDeleteFailedError,
     SemanticViewForbiddenError,
     SemanticViewInvalidError,
     SemanticViewNotFoundError,
@@ -46,13 +54,14 @@ from superset.commands.semantic_layer.update import (
     UpdateSemanticViewCommand,
 )
 from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP
-from superset.daos.semantic_layer import SemanticLayerDAO
+from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
 from superset.models.core import Database
 from superset.semantic_layers.models import SemanticLayer, SemanticView
 from superset.semantic_layers.registry import registry
 from superset.semantic_layers.schemas import (
     SemanticLayerPostSchema,
     SemanticLayerPutSchema,
+    SemanticViewPostSchema,
     SemanticViewPutSchema,
 )
 from superset.superset_typing import FlaskResponse
@@ -161,10 +170,89 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
     allow_browser_login = True
     class_permission_name = "SemanticView"
     method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
-    include_route_methods = {"put"}
+    include_route_methods = {"put", "post", "delete"}
 
     edit_model_schema = SemanticViewPutSchema()
 
+    @expose("/", methods=("POST",))
+    @protect()
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post",
+        log_to_statsd=False,
+    )
+    @requires_json
+    def post(self) -> Response:
+        """Bulk create semantic views.
+        ---
+        post:
+          summary: Create semantic views
+          requestBody:
+            required: true
+            content:
+              application/json:
+                schema:
+                  type: object
+                  properties:
+                    views:
+                      type: array
+                      items:
+                        type: object
+                        properties:
+                          name:
+                            type: string
+                          semantic_layer_uuid:
+                            type: string
+                          configuration:
+                            type: object
+                          description:
+                            type: string
+                          cache_timeout:
+                            type: integer
+          responses:
+            201:
+              description: Semantic views created
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            422:
+              $ref: '#/components/responses/422'
+        """
+        body = request.json or {}
+        views_data = body.get("views", [])
+        if not views_data:
+            return self.response_400(message="No views provided")
+
+        schema = SemanticViewPostSchema()
+        created = []
+        errors = []
+        for view_data in views_data:
+            try:
+                item = schema.load(view_data)
+            except ValidationError as error:
+                errors.append({"name": view_data.get("name"), "error": 
error.messages})
+                continue
+            try:
+                new_model = CreateSemanticViewCommand(item).run()
+                created.append({"uuid": str(new_model.uuid), "name": 
new_model.name})
+            except SemanticLayerNotFoundError:
+                errors.append(
+                    {"name": view_data.get("name"), "error": "Semantic layer 
not found"}
+                )
+            except SemanticViewCreateFailedError as ex:
+                logger.error(
+                    "Error creating semantic view: %s",
+                    str(ex),
+                    exc_info=True,
+                )
+                errors.append({"name": view_data.get("name"), "error": 
str(ex)})
+
+        result: dict[str, Any] = {"created": created}
+        if errors:
+            result["errors"] = errors
+        return self.response(201, result=result)
+
     @expose("/<pk>", methods=("PUT",))
     @protect()
     @statsd_metrics
@@ -238,6 +326,46 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
             response = self.response_422(message=str(ex))
         return response
 
+    @expose("/<pk>", methods=("DELETE",))
+    @protect()
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: 
f"{self.__class__.__name__}.delete",
+        log_to_statsd=False,
+    )
+    def delete(self, pk: int) -> Response:
+        """Delete a semantic view.
+        ---
+        delete:
+          summary: Delete a semantic view
+          parameters:
+          - in: path
+            schema:
+              type: integer
+            name: pk
+          responses:
+            200:
+              description: Semantic view deleted
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+        """
+        try:
+            DeleteSemanticViewCommand(pk).run()
+            return self.response(200, message="OK")
+        except SemanticViewNotFoundError:
+            return self.response_404()
+        except SemanticViewDeleteFailedError as ex:
+            logger.error(
+                "Error deleting semantic view: %s",
+                str(ex),
+                exc_info=True,
+            )
+            return self.response_422(message=str(ex))
+
 
 class SemanticLayerRestApi(BaseSupersetApi):
     resource_name = "semantic_layer"
@@ -368,6 +496,69 @@ class SemanticLayerRestApi(BaseSupersetApi):
 
         return self.response(200, result=schema)
 
+    @expose("/<uuid>/views", methods=("POST",))
+    @protect()
+    @safe
+    @statsd_metrics
+    def views(self, uuid: str) -> FlaskResponse:
+        """List available views from a semantic layer.
+        ---
+        post:
+          summary: List available views from a semantic layer
+          parameters:
+          - in: path
+            schema:
+              type: string
+            name: uuid
+          requestBody:
+            content:
+              application/json:
+                schema:
+                  type: object
+                  properties:
+                    runtime_data:
+                      type: object
+          responses:
+            200:
+              description: Available views
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+        """
+        layer = SemanticLayerDAO.find_by_uuid(uuid)
+        if not layer:
+            return self.response_404()
+
+        body = request.get_json(silent=True) or {}
+        runtime_data = body.get("runtime_data", {})
+
+        try:
+            views = layer.implementation.get_semantic_views(runtime_data)
+        except Exception as ex:  # pylint: disable=broad-except
+            return self.response_400(message=str(ex))
+
+        # Check which views already exist with the same runtime config
+        existing = SemanticViewDAO.find_by_semantic_layer(str(layer.uuid))
+        existing_keys: set[tuple[str, str]] = set()
+        for v in existing:
+            config = v.configuration
+            if isinstance(config, str):
+                config = json.loads(config)
+            existing_keys.add(
+                (v.name, json.dumps(config or {}, sort_keys=True))
+            )
+        runtime_key = json.dumps(runtime_data or {}, sort_keys=True)
+
+        result = [
+            {
+                "name": v.name,
+                "already_added": (v.name, runtime_key) in existing_keys,
+            }
+            for v in sorted(views, key=lambda v: v.name)
+        ]
+        return self.response(200, result=result)
+
     @expose("/", methods=("POST",))
     @protect()
     @safe
diff --git a/superset/semantic_layers/schemas.py 
b/superset/semantic_layers/schemas.py
index d10e0fd28fb..c0d835a7354 100644
--- a/superset/semantic_layers/schemas.py
+++ b/superset/semantic_layers/schemas.py
@@ -35,3 +35,11 @@ class SemanticLayerPutSchema(Schema):
     description = fields.String(allow_none=True)
     configuration = fields.Dict()
     cache_timeout = fields.Integer(allow_none=True)
+
+
+class SemanticViewPostSchema(Schema):
+    name = fields.String(required=True)
+    semantic_layer_uuid = fields.String(required=True)
+    configuration = fields.Dict(load_default=dict)
+    description = fields.String(allow_none=True)
+    cache_timeout = fields.Integer(allow_none=True)
diff --git a/superset/static/service-worker.js 
b/superset/static/service-worker.js
index 88dce3da348..74fc10a3285 100644
--- a/superset/static/service-worker.js
+++ b/superset/static/service-worker.js
@@ -170,7 +170,7 @@ eval("{/**\n * Licensed to the Apache Software Foundation 
(ASF) under one\n * or
 /******/       
 /******/       /* webpack/runtime/getFullHash */
 /******/       (() => {
-/******/               __webpack_require__.h = () => ("9e4777e49256d5920929")
+/******/               __webpack_require__.h = () => ("7be3989f0fd5f505f982")
 /******/       })();
 /******/       
 /******/       /* webpack/runtime/harmony module decorator */

Reply via email to