You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by be...@apache.org on 2026/03/04 03:35:09 UTC
(superset) branch semantic-layer-add-semantic-view updated (7ac3b58ac8f -> 8746609df8c)
This is an automated email from the ASF dual-hosted git repository.
beto pushed a change to branch semantic-layer-add-semantic-view
in repository https://gitbox.apache.org/repos/asf/superset.git
discard 7ac3b58ac8f feat: CRUD for adding/deleting semantic views
omit 26314ef1f1d feat(semantic layers): UI for semantic layers
new 0cfb4c37141 feat(semantic layers): UI for semantic layers
new 8746609df8c feat: CRUD for adding/deleting semantic views
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (7ac3b58ac8f)
\
N -- N -- N refs/heads/semantic-layer-add-semantic-view (8746609df8c)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
superset/static/service-worker.js | 2 +-
tests/unit_tests/semantic_layers/api_test.py | 57 ++++++++++++++++++++++++++++
2 files changed, 58 insertions(+), 1 deletion(-)
(superset) 02/02: feat: CRUD for adding/deleting semantic views
Posted by be...@apache.org.
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 8746609df8c58de0fe1f93bffaa036b8c0a5f124
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Tue Feb 17 11:50:06 2026 -0500
feat: CRUD for adding/deleting semantic views
---
.../features/semanticLayers/SemanticLayerModal.tsx | 267 ++----------
.../features/semanticLayers/jsonFormsHelpers.tsx | 257 +++++++++++
.../semanticViews/AddSemanticViewModal.tsx | 475 +++++++++++++++++++++
superset-frontend/src/pages/DatasetList/index.tsx | 173 ++++++--
superset/commands/semantic_layer/create.py | 31 +-
superset/commands/semantic_layer/delete.py | 29 +-
superset/commands/semantic_layer/exceptions.py | 8 +
superset/daos/semantic_layer.py | 24 +-
superset/semantic_layers/api.py | 197 ++++++++-
superset/semantic_layers/schemas.py | 8 +
10 files changed, 1187 insertions(+), 282 deletions(-)
diff --git a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx
index 1b18fb039d1..9b1e9bba886 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,231 +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,
-};
-
-/**
- * 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;
- });
-}
-
-/**
- * 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 } as JsonSchema;
-}
-
-/**
- * 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;
-}
-
-
-
-/**
- * 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;
`;
@@ -397,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.
@@ -528,7 +301,13 @@ export default function SemanticLayerModal({
);
const handleFormChange = useCallback(
- ({ data, errors }: { data: Record<string, unknown>; errors?: ErrorObject[] }) => {
+ ({
+ data,
+ errors,
+ }: {
+ data: Record<string, unknown>;
+ errors?: ErrorObject[];
+ }) => {
setFormData(data);
errorsRef.current = errors ?? [];
setHasErrors(errorsRef.current.length > 0);
@@ -563,7 +342,9 @@ export default function SemanticLayerModal({
saveDisabled={
step === 'type' ? !selectedType : saving || !name.trim() || hasErrors
}
- saveText={step === 'type' ? undefined : isEditMode ? t('Save') : t('Create')}
+ saveText={
+ step === 'type' ? undefined : isEditMode ? t('Save') : t('Create')
+ }
saveLoading={saving}
contentLoading={loading}
>
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..505eab8f18b
--- /dev/null
+++ b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
@@ -0,0 +1,475 @@
+/**
+ * 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 fabd1379a4b..78eb5726ac8 100644
--- a/superset-frontend/src/pages/DatasetList/index.tsx
+++ b/superset-frontend/src/pages/DatasetList/index.tsx
@@ -37,9 +37,11 @@ import { OWNER_OPTION_FILTER_PROPS } from 'src/features/owners/OwnerSelectLabel'
import { ColumnObject } from 'src/features/datasets/types';
import { useListViewResource } from 'src/views/CRUD/hooks';
import {
+ Button,
ConfirmStatusChange,
CertifiedBadge,
DeleteModal,
+ Dropdown,
Tooltip,
InfoTooltip,
DatasetTypeLabel,
@@ -75,6 +77,7 @@ import {
import DuplicateDatasetModal from 'src/features/datasets/DuplicateDatasetModal';
import type DatasetType from 'src/types/Dataset';
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';
@@ -265,6 +268,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);
@@ -527,25 +532,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>
);
}
@@ -852,14 +875,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;
@@ -893,15 +960,56 @@ 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(json.message);
+ 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(t('Deleted %s item(s)', datasetsToDelete.length));
},
createErrorHandler(errMsg =>
addDangerToast(
@@ -1073,6 +1181,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 8729f75a13b..7cb78df8a5f 100644
--- a/superset/commands/semantic_layer/create.py
+++ b/superset/commands/semantic_layer/create.py
@@ -16,7 +16,6 @@
# under the License.
from __future__ import annotations
-import json
import logging
from functools import partial
from typing import Any
@@ -28,9 +27,12 @@ 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 import json
from superset.utils.decorators import on_error, transaction
logger = logging.getLogger(__name__)
@@ -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 3271cb76306..d837102e265 100644
--- a/superset/daos/semantic_layer.py
+++ b/superset/daos/semantic_layer.py
@@ -22,6 +22,7 @@ from __future__ import annotations
from superset.daos.base import BaseDAO
from superset.extensions import db
from superset.semantic_layers.models import SemanticLayer, SemanticView
+from superset.utils import json
class SemanticLayerDAO(BaseDAO[SemanticLayer]):
@@ -123,18 +124,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 1b4d5c6db71..2913309e560 100644
--- a/superset/semantic_layers/api.py
+++ b/superset/semantic_layers/api.py
@@ -27,14 +27,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,
@@ -45,13 +53,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"
@@ -369,6 +497,67 @@ 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)
(superset) 01/02: feat(semantic layers): UI for semantic layers
Posted by be...@apache.org.
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 0cfb4c371411645635a1a50da3b7cb33ed8a2ae2
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Tue Mar 3 18:41:51 2026 -0500
feat(semantic layers): UI for semantic layers
---
.../src/superset_core/semantic_layers/config.py | 73 +
.../semantic_layers/semantic_layer.py | 2 +
superset-frontend/package-lock.json | 215 ++-
superset-frontend/package.json | 7 +
superset-frontend/src/features/home/SubMenu.tsx | 29 +-
.../features/semanticLayers/SemanticLayerModal.tsx | 622 ++++++++
superset-frontend/src/pages/DatabaseList/index.tsx | 467 ++++--
superset/commands/semantic_layer/create.py | 5 +
superset/commands/semantic_layer/update.py | 5 +
superset/semantic_layers/api.py | 275 +++-
superset/static/service-worker.js | 1492 +++++++++++++++++++-
.../commands/semantic_layer/create_test.py | 3 +-
tests/unit_tests/semantic_layers/api_test.py | 588 +++++++-
13 files changed, 3633 insertions(+), 150 deletions(-)
diff --git a/superset-core/src/superset_core/semantic_layers/config.py b/superset-core/src/superset_core/semantic_layers/config.py
new file mode 100644
index 00000000000..c1b92a21008
--- /dev/null
+++ b/superset-core/src/superset_core/semantic_layers/config.py
@@ -0,0 +1,73 @@
+# 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.
+
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import BaseModel
+
+
+def build_configuration_schema(
+ config_class: type[BaseModel],
+ configuration: BaseModel | None = None,
+) -> dict[str, Any]:
+ """
+ Build a JSON schema from a Pydantic configuration class.
+
+ Handles generic boilerplate that any semantic layer with dynamic fields needs:
+
+ - Reorders properties to match model field order (Pydantic sorts alphabetically)
+ - When ``configuration`` is None, sets ``enum: []`` on all ``x-dynamic`` properties
+ so the frontend renders them as empty dropdowns
+
+ Semantic layer implementations call this instead of ``model_json_schema()`` directly,
+ then only need to add their own dynamic population logic.
+ """
+ schema = config_class.model_json_schema()
+
+ # Pydantic sorts properties alphabetically; restore model field order
+ field_order = [
+ field.alias or name
+ for name, field in config_class.model_fields.items()
+ ]
+ schema["properties"] = {
+ key: schema["properties"][key]
+ for key in field_order
+ if key in schema["properties"]
+ }
+
+ if configuration is None:
+ for prop_schema in schema["properties"].values():
+ if prop_schema.get("x-dynamic"):
+ prop_schema["enum"] = []
+
+ return schema
+
+
+def check_dependencies(
+ prop_schema: dict[str, Any],
+ configuration: BaseModel,
+) -> bool:
+ """
+ Check whether a dynamic property's dependencies are satisfied.
+
+ Reads the ``x-dependsOn`` list from the property schema and returns ``True``
+ when every referenced attribute on ``configuration`` is truthy.
+ """
+ dependencies = prop_schema.get("x-dependsOn", [])
+ return all(getattr(configuration, dep, None) for dep in dependencies)
diff --git a/superset-core/src/superset_core/semantic_layers/semantic_layer.py b/superset-core/src/superset_core/semantic_layers/semantic_layer.py
index 1fc421cf359..fb79f1dab5a 100644
--- a/superset-core/src/superset_core/semantic_layers/semantic_layer.py
+++ b/superset-core/src/superset_core/semantic_layers/semantic_layer.py
@@ -32,6 +32,8 @@ class SemanticLayer(ABC, Generic[ConfigT, SemanticViewT]):
Abstract base class for semantic layers.
"""
+ configuration_class: type[BaseModel]
+
@classmethod
@abstractmethod
def from_configuration(
diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json
index a819d9c7488..33aab659d33 100644
--- a/superset-frontend/package-lock.json
+++ b/superset-frontend/package-lock.json
@@ -28,7 +28,13 @@
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
+ "@fontsource/fira-code": "^5.2.7",
"@fontsource/ibm-plex-mono": "^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",
"@luma.gl/constants": "~9.2.5",
"@luma.gl/core": "~9.2.5",
"@luma.gl/engine": "~9.2.5",
@@ -36,6 +42,7 @@
"@luma.gl/shadertools": "~9.2.5",
"@luma.gl/webgl": "~9.2.5",
"@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",
@@ -4044,6 +4051,15 @@
}
}
},
+ "node_modules/@fontsource/fira-code": {
+ "version": "5.2.7",
+ "resolved": "https://registry.npmjs.org/@fontsource/fira-code/-/fira-code-5.2.7.tgz",
+ "integrity": "sha512-tnB9NNund9TwIym8/7DMJe573nlPEQb+fKUV5GL8TBYXjIhDvL0D7mgmNVNQUPhXp+R7RylQeiBdkA4EbOHPGQ==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
"node_modules/@fontsource/ibm-plex-mono": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.7.tgz",
@@ -4054,15 +4070,34 @@
}
},
"node_modules/@fontsource/inter": {
- "version": "5.2.6",
- "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.6.tgz",
- "integrity": "sha512-CZs9S1CrjD0jPwsNy9W6j0BhsmRSQrgwlTNkgQXTsAeDRM42LBRLo3eo9gCzfH4GvV7zpyf78Ozfl773826csw==",
+ "version": "5.2.8",
+ "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
+ "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
"license": "OFL-1.1",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
+ "node_modules/@great-expectations/jsonforms-antd-renderers": {
+ "version": "2.2.11",
+ "resolved": "https://registry.npmjs.org/@great-expectations/jsonforms-antd-renderers/-/jsonforms-antd-renderers-2.2.11.tgz",
+ "integrity": "sha512-QeKI6RP+vZo5Bf5WX5Mx6CPEBYvR83bIyeezHoyVVc1+pGsDqO9lsFdbaFKpqozV+s/TRB1KmVAW4GxpMzLuAw==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash.isempty": "^4.4.0",
+ "lodash.merge": "^4.6.2",
+ "lodash.range": "^3.2.0",
+ "lodash.startcase": "^4.4.0"
+ },
+ "peerDependencies": {
+ "@ant-design/icons": "^5.3.0",
+ "@jsonforms/core": "^3.3.0",
+ "@jsonforms/react": "^3.3.0",
+ "antd": "^5.14.0",
+ "dayjs": "^1",
+ "react": "^17 || ^18"
+ }
+ },
"node_modules/@hapi/address": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz",
@@ -6095,6 +6130,45 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@jsonforms/core": {
+ "version": "3.7.0",
+ "resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-3.7.0.tgz",
+ "integrity": "sha512-CE9viWtwi9QWLqlWLeOul1/R1GRAyOA9y6OoUpsCc0FhyR+g5p29F3k0fUExHWxL0Sf4KHcXYkfhtqfRBPS8ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/json-schema": "^7.0.3",
+ "ajv": "^8.6.1",
+ "ajv-formats": "^2.1.0",
+ "lodash": "^4.17.21"
+ }
+ },
+ "node_modules/@jsonforms/react": {
+ "version": "3.7.0",
+ "resolved": "https://registry.npmjs.org/@jsonforms/react/-/react-3.7.0.tgz",
+ "integrity": "sha512-HkY7qAx8vW97wPEgZ7GxCB3iiXG1c95GuObxtcDHGPBJWMwnxWBnVYJmv5h7nthrInKsQKHZL5OusnC/sj/1GQ==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash": "^4.17.21"
+ },
+ "peerDependencies": {
+ "@jsonforms/core": "3.7.0",
+ "react": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@jsonforms/vanilla-renderers": {
+ "version": "3.7.0",
+ "resolved": "https://registry.npmjs.org/@jsonforms/vanilla-renderers/-/vanilla-renderers-3.7.0.tgz",
+ "integrity": "sha512-RdXQGsheARUJVbaTe6SqGw9W4/yrm0BgUok6OKUj8krp1NF4fqXc5UbYGHFksMR/p7LCuoYHCtQzKLXEfxJbDw==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash": "^4.17.21"
+ },
+ "peerDependencies": {
+ "@jsonforms/core": "3.7.0",
+ "@jsonforms/react": "3.7.0",
+ "react": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/@jsonjoy.com/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz",
@@ -9964,6 +10038,89 @@
"integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==",
"license": "MIT"
},
+ "node_modules/@rjsf/antd": {
+ "version": "5.24.13",
+ "resolved": "https://registry.npmjs.org/@rjsf/antd/-/antd-5.24.13.tgz",
+ "integrity": "sha512-UiWE8xoBxxCoe/SEkdQEmL5E6z3I1pw0+y0dTyGt8SHfAxxFc4/OWn7tKOAiNsKCXgf83t0JKn6CHWLD01sAdQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "classnames": "^2.5.1",
+ "lodash": "^4.17.21",
+ "lodash-es": "^4.17.21",
+ "rc-picker": "2.7.6"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "@ant-design/icons": "^4.0.0 || ^5.0.0",
+ "@rjsf/core": "^5.24.x",
+ "@rjsf/utils": "^5.24.x",
+ "antd": "^4.24.0 || ^5.8.5",
+ "dayjs": "^1.8.0",
+ "react": "^16.14.0 || >=17"
+ }
+ },
+ "node_modules/@rjsf/antd/node_modules/rc-picker": {
+ "version": "2.7.6",
+ "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-2.7.6.tgz",
+ "integrity": "sha512-H9if/BUJUZBOhPfWcPeT15JUI3/ntrG9muzERrXDkSoWmDj4yzmBvumozpxYrHwjcKnjyDGAke68d+whWwvhHA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "classnames": "^2.2.1",
+ "date-fns": "2.x",
+ "dayjs": "1.x",
+ "moment": "^2.24.0",
+ "rc-trigger": "^5.0.4",
+ "rc-util": "^5.37.0",
+ "shallowequal": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@rjsf/antd/node_modules/rc-picker/node_modules/rc-trigger": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-5.3.4.tgz",
+ "integrity": "sha512-mQv+vas0TwKcjAO2izNPkqR4j86OemLRmvL2nOzdP9OWNWA1ivoTt5hzFqYNW9zACwmTezRiN8bttrC7cZzYSw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.3",
+ "classnames": "^2.2.6",
+ "rc-align": "^4.0.0",
+ "rc-motion": "^2.0.0",
+ "rc-util": "^5.19.2"
+ },
+ "engines": {
+ "node": ">=8.x"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@rjsf/antd/node_modules/rc-picker/node_modules/rc-trigger/node_modules/rc-align": {
+ "version": "4.0.15",
+ "resolved": "https://registry.npmjs.org/rc-align/-/rc-align-4.0.15.tgz",
+ "integrity": "sha512-wqJtVH60pka/nOX7/IspElA8gjPNQKIx/ZqJ6heATCkXpe1Zg4cPVrMD2vC96wjsFFL8WsmhPbx9tdMo1qqlIA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.10.1",
+ "classnames": "2.x",
+ "dom-align": "^1.7.0",
+ "rc-util": "^5.26.0",
+ "resize-observer-polyfill": "^1.5.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
"node_modules/@rjsf/core": {
"version": "5.24.13",
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-5.24.13.tgz",
@@ -22919,6 +23076,22 @@
"integrity": "sha512-O/gRkjWULp3xVX8K85V0H3tsSGole0WYt77KVpGZO2xTGLuVFuvE6JIsIli3fvFHCYBhGFn/8OHEEyMYF+QehA==",
"license": "MIT"
},
+ "node_modules/date-fns": {
+ "version": "2.30.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
+ "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.21.0"
+ },
+ "engines": {
+ "node": ">=0.11"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/date-fns"
+ }
+ },
"node_modules/dateformat": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.2.tgz",
@@ -23524,6 +23697,12 @@
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"license": "MIT"
},
+ "node_modules/dom-align": {
+ "version": "1.12.4",
+ "resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz",
+ "integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==",
+ "license": "MIT"
+ },
"node_modules/dom-converter": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
@@ -36356,6 +36535,12 @@
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
"license": "MIT"
},
+ "node_modules/lodash.isempty": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz",
+ "integrity": "sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==",
+ "license": "MIT"
+ },
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
@@ -36387,7 +36572,6 @@
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/lodash.once": {
@@ -36398,6 +36582,18 @@
"license": "MIT",
"peer": true
},
+ "node_modules/lodash.range": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.range/-/lodash.range-3.2.0.tgz",
+ "integrity": "sha512-Fgkb7SinmuzqgIhNhAElo0BL/R1rHCnhwSZf78omqSwvWqD0kD2ssOAutQonDKH/ldS8BxA72ORYI09qAY9CYg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.startcase": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz",
+ "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==",
+ "license": "MIT"
+ },
"node_modules/lodash.uniq": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
@@ -37908,7 +38104,6 @@
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
- "peer": true,
"engines": {
"node": "*"
}
@@ -45409,6 +45604,12 @@
"integrity": "sha512-b6i4ZpVuUxB9h5gfCxPiusKYkqTMOjEbBs4wMaFbkfia4yFv92UKZ6Df8WXcKbn08JNL/abvg3FnMAOfakDvUw==",
"license": "MIT"
},
+ "node_modules/shallowequal": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
+ "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
+ "license": "MIT"
+ },
"node_modules/shapefile": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/shapefile/-/shapefile-0.3.1.tgz",
@@ -52199,7 +52400,7 @@
"react-js-cron": "^5.2.0",
"react-markdown": "^8.0.7",
"react-resize-detector": "^7.1.2",
- "react-syntax-highlighter": "^16.1.0",
+ "react-syntax-highlighter": "^16.1.1",
"react-ultimate-pagination": "^1.3.2",
"regenerator-runtime": "^0.14.1",
"rehype-raw": "^7.0.0",
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/home/SubMenu.tsx b/superset-frontend/src/features/home/SubMenu.tsx
index 107700a2539..bb27cc95a76 100644
--- a/superset-frontend/src/features/home/SubMenu.tsx
+++ b/superset-frontend/src/features/home/SubMenu.tsx
@@ -145,6 +145,7 @@ export interface ButtonProps {
buttonStyle: 'primary' | 'secondary' | 'dashed' | 'link' | 'tertiary';
loading?: boolean;
icon?: IconType;
+ component?: ReactNode;
}
export interface SubMenuProps {
@@ -295,18 +296,22 @@ const SubMenuComponent: FunctionComponent<SubMenuProps> = props => {
</SubMenu>
))}
</Menu>
- {props.buttons?.map((btn, i) => (
- <Button
- key={i}
- buttonStyle={btn.buttonStyle}
- icon={btn.icon}
- onClick={btn.onClick}
- data-test={btn['data-test']}
- loading={btn.loading ?? false}
- >
- {btn.name}
- </Button>
- ))}
+ {props.buttons?.map((btn, i) =>
+ btn.component ? (
+ <span key={i}>{btn.component}</span>
+ ) : (
+ <Button
+ key={i}
+ buttonStyle={btn.buttonStyle}
+ icon={btn.icon}
+ onClick={btn.onClick}
+ data-test={btn['data-test']}
+ loading={btn.loading ?? false}
+ >
+ {btn.name}
+ </Button>
+ ),
+ )}
</div>
</Row>
{props.children}
diff --git a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx
new file mode 100644
index 00000000000..1b18fb039d1
--- /dev/null
+++ b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx
@@ -0,0 +1,622 @@
+/**
+ * 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 { Input, Spin } 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 type { ErrorObject } from 'ajv';
+import {
+ StandardModal,
+ ModalFormField,
+ 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,
+};
+
+/**
+ * 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;
+ });
+}
+
+/**
+ * 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,
+];
+
+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 } as JsonSchema;
+}
+
+/**
+ * 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;
+}
+
+
+
+/**
+ * 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;
+`;
+
+const BackLink = styled.button`
+ background: none;
+ border: none;
+ color: ${({ theme }) => theme.colorPrimary};
+ cursor: pointer;
+ padding: 0;
+ font-size: ${({ theme }) => theme.fontSize}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;
+ semanticLayerUuid?: string;
+}
+
+export default function SemanticLayerModal({
+ show,
+ onHide,
+ addDangerToast,
+ addSuccessToast,
+ semanticLayerUuid,
+}: SemanticLayerModalProps) {
+ const isEditMode = !!semanticLayerUuid;
+ const [step, setStep] = useState<Step>('type');
+ const [name, setName] = useState('');
+ 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 [hasErrors, setHasErrors] = useState(true);
+ const [refreshingSchema, setRefreshingSchema] = useState(false);
+ const [validationMode, setValidationMode] =
+ useState<ValidationMode>('ValidateAndHide');
+ const errorsRef = useRef<ErrorObject[]>([]);
+ const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+ const lastDepSnapshotRef = useRef<string>('');
+ const dynamicDepsRef = useRef<Record<string, string[]>>({});
+
+ 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 applySchema = useCallback((rawSchema: JsonSchema) => {
+ const schema = sanitizeSchema(rawSchema);
+ setConfigSchema(schema);
+ setUiSchema(buildUiSchema(schema));
+ dynamicDepsRef.current = getDynamicDependencies(rawSchema);
+ }, []);
+
+ const fetchConfigSchema = useCallback(
+ async (type: string, configuration?: Record<string, unknown>) => {
+ const isInitialFetch = !configuration;
+ if (isInitialFetch) setLoading(true);
+ else setRefreshingSchema(true);
+ try {
+ const { json } = await SupersetClient.post({
+ endpoint: '/api/v1/semantic_layer/schema/configuration',
+ jsonPayload: { type, configuration },
+ });
+ applySchema(json.result);
+ if (isInitialFetch) setStep('config');
+ } catch {
+ if (isInitialFetch) {
+ addDangerToast(
+ t('An error occurred while fetching the configuration schema'),
+ );
+ }
+ } finally {
+ if (isInitialFetch) setLoading(false);
+ else setRefreshingSchema(false);
+ }
+ },
+ [addDangerToast, applySchema],
+ );
+
+ const fetchExistingLayer = useCallback(
+ async (uuid: string) => {
+ setLoading(true);
+ try {
+ const { json } = await SupersetClient.get({
+ endpoint: `/api/v1/semantic_layer/${uuid}`,
+ });
+ const layer = json.result;
+ setName(layer.name ?? '');
+ setSelectedType(layer.type);
+ setFormData(layer.configuration ?? {});
+ setHasErrors(false);
+ // 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.
+ const { json: schemaJson } = await SupersetClient.post({
+ endpoint: '/api/v1/semantic_layer/schema/configuration',
+ jsonPayload: { type: layer.type },
+ });
+ applySchema(schemaJson.result);
+ setStep('config');
+ } catch {
+ addDangerToast(
+ t('An error occurred while fetching the semantic layer'),
+ );
+ } finally {
+ setLoading(false);
+ }
+ },
+ [addDangerToast, applySchema],
+ );
+
+ useEffect(() => {
+ if (show) {
+ if (isEditMode && semanticLayerUuid) {
+ fetchTypes();
+ fetchExistingLayer(semanticLayerUuid);
+ } else {
+ fetchTypes();
+ }
+ } else {
+ setStep('type');
+ setName('');
+ setSelectedType(null);
+ setTypes([]);
+ setConfigSchema(null);
+ setUiSchema(undefined);
+ setFormData({});
+ setHasErrors(true);
+ setRefreshingSchema(false);
+ setValidationMode('ValidateAndHide');
+ errorsRef.current = [];
+ lastDepSnapshotRef.current = '';
+ dynamicDepsRef.current = {};
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
+ }
+ }, [show, fetchTypes, isEditMode, semanticLayerUuid, fetchExistingLayer]);
+
+ const handleStepAdvance = () => {
+ if (selectedType) {
+ fetchConfigSchema(selectedType);
+ }
+ };
+
+ const handleBack = () => {
+ setStep('type');
+ setConfigSchema(null);
+ setUiSchema(undefined);
+ setFormData({});
+ setValidationMode('ValidateAndHide');
+ errorsRef.current = [];
+ lastDepSnapshotRef.current = '';
+ dynamicDepsRef.current = {};
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
+ };
+
+ const handleCreate = async () => {
+ setSaving(true);
+ try {
+ if (isEditMode && semanticLayerUuid) {
+ await SupersetClient.put({
+ endpoint: `/api/v1/semantic_layer/${semanticLayerUuid}`,
+ jsonPayload: { name, configuration: formData },
+ });
+ addSuccessToast(t('Semantic layer updated'));
+ } else {
+ await SupersetClient.post({
+ endpoint: '/api/v1/semantic_layer/',
+ jsonPayload: { name, type: selectedType, configuration: formData },
+ });
+ addSuccessToast(t('Semantic layer created'));
+ }
+ onHide();
+ } catch {
+ addDangerToast(
+ isEditMode
+ ? t('An error occurred while updating the semantic layer')
+ : 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 maybeRefreshSchema = useCallback(
+ (data: Record<string, unknown>) => {
+ if (!selectedType) return;
+
+ const dynamicDeps = dynamicDepsRef.current;
+ if (Object.keys(dynamicDeps).length === 0) return;
+
+ // Check if any dynamic field has all dependencies satisfied
+ const hasSatisfiedDeps = Object.values(dynamicDeps).some(deps =>
+ areDependenciesSatisfied(deps, data),
+ );
+ if (!hasSatisfiedDeps) return;
+
+ // Only re-fetch if dependency values actually changed
+ const snapshot = serializeDependencyValues(dynamicDeps, data);
+ if (snapshot === lastDepSnapshotRef.current) return;
+ lastDepSnapshotRef.current = snapshot;
+
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
+ debounceTimerRef.current = setTimeout(() => {
+ fetchConfigSchema(selectedType, data);
+ }, SCHEMA_REFRESH_DEBOUNCE_MS);
+ },
+ [selectedType, fetchConfigSchema],
+ );
+
+ const handleFormChange = useCallback(
+ ({ data, errors }: { data: Record<string, unknown>; errors?: ErrorObject[] }) => {
+ setFormData(data);
+ errorsRef.current = errors ?? [];
+ setHasErrors(errorsRef.current.length > 0);
+ if (
+ validationMode === 'ValidateAndShow' &&
+ errorsRef.current.length === 0
+ ) {
+ handleCreate();
+ }
+ maybeRefreshSchema(data);
+ },
+ [validationMode, handleCreate, maybeRefreshSchema],
+ );
+
+ const selectedTypeName =
+ types.find(type => type.id === selectedType)?.name ?? '';
+
+ const title = isEditMode
+ ? t('Edit %s', selectedTypeName || t('Semantic Layer'))
+ : step === 'type'
+ ? t('New Semantic Layer')
+ : t('Configure %s', selectedTypeName);
+
+ return (
+ <StandardModal
+ show={show}
+ onHide={onHide}
+ onSave={handleSave}
+ title={title}
+ icon={isEditMode ? <Icons.EditOutlined /> : <Icons.PlusOutlined />}
+ width={step === 'type' ? MODAL_STANDARD_WIDTH : MODAL_MEDIUM_WIDTH}
+ saveDisabled={
+ step === 'type' ? !selectedType : saving || !name.trim() || hasErrors
+ }
+ saveText={step === 'type' ? undefined : isEditMode ? t('Save') : 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>
+ {!isEditMode && (
+ <BackLink type="button" onClick={handleBack}>
+ <Icons.CaretLeftOutlined iconSize="s" />
+ {t('Back')}
+ </BackLink>
+ )}
+ <ModalFormField label={t('Name')} required>
+ <Input
+ value={name}
+ onChange={e => setName(e.target.value)}
+ placeholder={t('Name of the semantic layer')}
+ />
+ </ModalFormField>
+ {configSchema && (
+ <JsonForms
+ schema={configSchema}
+ uischema={uiSchema}
+ data={formData}
+ renderers={renderers}
+ cells={cellRegistryEntries}
+ config={{ refreshingSchema, formData }}
+ 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 e8bf7ab29c2..b7dc78ad229 100644
--- a/superset-frontend/src/pages/DatabaseList/index.tsx
+++ b/superset-frontend/src/pages/DatabaseList/index.tsx
@@ -17,8 +17,13 @@
* under the License.
*/
import { t } from '@apache-superset/core';
-import { getExtensionsRegistry, SupersetClient } from '@superset-ui/core';
-import { styled } from '@apache-superset/core/ui';
+import {
+ getExtensionsRegistry,
+ SupersetClient,
+ isFeatureEnabled,
+ FeatureFlag,
+} from '@superset-ui/core';
+import { css, styled, useTheme } from '@apache-superset/core/ui';
import { useState, useMemo, useEffect, useCallback } from 'react';
import rison from 'rison';
import { useSelector } from 'react-redux';
@@ -33,7 +38,9 @@ import {
import withToasts from 'src/components/MessageToasts/withToasts';
import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
import {
+ Button,
DeleteModal,
+ Dropdown,
Tooltip,
List,
Loading,
@@ -43,6 +50,7 @@ import {
ListView,
ListViewFilterOperator as FilterOperator,
ListViewFilters,
+ type ListViewFetchDataConfig,
} from 'src/components';
import { Typography } from '@superset-ui/core/components/Typography';
import { getUrlParam } from 'src/utils/urlUtils';
@@ -55,6 +63,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';
@@ -70,6 +79,11 @@ const dbConfigExtraExtension = extensionsRegistry.get(
const PAGE_SIZE = 25;
+type ConnectionItem = DatabaseObject & {
+ source_type?: 'database' | 'semantic_layer';
+ sl_type?: string;
+};
+
interface DatabaseDeleteObject extends DatabaseObject {
charts: any;
dashboards: any;
@@ -108,20 +122,106 @@ function DatabaseList({
addSuccessToast,
user,
}: DatabaseListProps) {
+ const theme = useTheme();
+ const showSemanticLayers = isFeatureEnabled(FeatureFlag.SemanticLayers);
+
+ // Standard database list view resource (used when SL flag is OFF)
const {
state: {
- loading,
- resourceCount: databaseCount,
- resourceCollection: databases,
+ loading: dbLoading,
+ resourceCount: dbCount,
+ resourceCollection: dbCollection,
},
hasPerm,
- fetchData,
- refreshData,
+ fetchData: dbFetchData,
+ refreshData: dbRefreshData,
} = useListViewResource<DatabaseObject>(
'database',
t('database'),
addDangerToast,
);
+
+ // Combined endpoint state (used when SL flag is ON)
+ const [combinedItems, setCombinedItems] = useState<ConnectionItem[]>([]);
+ const [combinedCount, setCombinedCount] = useState(0);
+ const [combinedLoading, setCombinedLoading] = useState(true);
+ const [lastFetchConfig, setLastFetchConfig] =
+ useState<ListViewFetchDataConfig | null>(null);
+
+ const combinedFetchData = useCallback(
+ (config: ListViewFetchDataConfig) => {
+ setLastFetchConfig(config);
+ setCombinedLoading(true);
+ const { pageIndex, pageSize, sortBy, filters: filterValues } = config;
+
+ const sourceTypeFilter = filterValues.find(f => f.id === 'source_type');
+ const otherFilters = filterValues
+ .filter(f => f.id !== 'source_type')
+ .filter(
+ ({ value }) => value !== '' && value !== null && value !== undefined,
+ )
+ .map(({ id, operator: opr, value }) => ({
+ col: id,
+ opr,
+ value:
+ value && typeof value === 'object' && 'value' in value
+ ? value.value
+ : value,
+ }));
+
+ const sourceTypeValue =
+ sourceTypeFilter?.value && typeof sourceTypeFilter.value === 'object'
+ ? (sourceTypeFilter.value as { value: string }).value
+ : (sourceTypeFilter?.value as string | undefined);
+ if (sourceTypeValue) {
+ otherFilters.push({
+ col: 'source_type',
+ opr: 'eq',
+ value: sourceTypeValue,
+ });
+ }
+
+ const queryParams = rison.encode_uri({
+ order_column: sortBy[0].id,
+ order_direction: sortBy[0].desc ? 'desc' : 'asc',
+ page: pageIndex,
+ page_size: pageSize,
+ ...(otherFilters.length ? { filters: otherFilters } : {}),
+ });
+
+ return SupersetClient.get({
+ endpoint: `/api/v1/semantic_layer/connections/?q=${queryParams}`,
+ })
+ .then(({ json = {} }) => {
+ setCombinedItems(json.result);
+ setCombinedCount(json.count);
+ })
+ .catch(() => {
+ addDangerToast(t('An error occurred while fetching connections'));
+ })
+ .finally(() => {
+ setCombinedLoading(false);
+ });
+ },
+ [addDangerToast],
+ );
+
+ const combinedRefreshData = useCallback(() => {
+ if (lastFetchConfig) {
+ return combinedFetchData(lastFetchConfig);
+ }
+ return undefined;
+ }, [lastFetchConfig, combinedFetchData]);
+
+ // Select the right data source based on feature flag
+ const loading = showSemanticLayers ? combinedLoading : dbLoading;
+ const databaseCount = showSemanticLayers ? combinedCount : dbCount;
+ const databases: ConnectionItem[] = showSemanticLayers
+ ? combinedItems
+ : dbCollection;
+ const fetchData = showSemanticLayers ? combinedFetchData : dbFetchData;
+ const refreshData = showSemanticLayers ? combinedRefreshData : dbRefreshData;
+
const fullUser = useSelector<any, UserWithPermissionsAndRoles>(
state => state.user,
);
@@ -148,6 +248,13 @@ function DatabaseList({
useState<boolean>(false);
const [columnarUploadDataModalOpen, setColumnarUploadDataModalOpen] =
useState<boolean>(false);
+ const [semanticLayerModalOpen, setSemanticLayerModalOpen] =
+ useState<boolean>(false);
+ const [slCurrentlyEditing, setSlCurrentlyEditing] = useState<string | null>(
+ null,
+ );
+ const [slCurrentlyDeleting, setSlCurrentlyDeleting] =
+ useState<ConnectionItem | null>(null);
const [allowUploads, setAllowUploads] = useState<boolean>(false);
const isAdmin = isUserAdmin(fullUser);
@@ -320,18 +427,63 @@ function DatabaseList({
};
if (canCreate) {
- menuData.buttons = [
- {
- 'data-test': 'btn-create-database',
- icon: <Icons.PlusOutlined iconSize="m" />,
- name: t('Database'),
- buttonStyle: 'primary',
- onClick: () => {
- // Ensure modal will be opened in add mode
- handleDatabaseEditModal({ modalOpen: true });
+ const openDatabaseModal = () =>
+ handleDatabaseEditModal({ modalOpen: true });
+
+ if (isFeatureEnabled(FeatureFlag.SemanticLayers)) {
+ menuData.buttons = [
+ {
+ name: t('New'),
+ buttonStyle: 'primary',
+ component: (
+ <Dropdown
+ menu={{
+ items: [
+ {
+ key: 'database',
+ label: t('Database'),
+ onClick: openDatabaseModal,
+ },
+ {
+ key: 'semantic-layer',
+ label: t('Semantic Layer'),
+ onClick: () => {
+ setSemanticLayerModalOpen(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 {
+ menuData.buttons = [
+ {
+ 'data-test': 'btn-create-database',
+ icon: <Icons.PlusOutlined iconSize="m" />,
+ name: t('Database'),
+ buttonStyle: 'primary',
+ onClick: openDatabaseModal,
+ },
+ ];
+ }
}
const handleDatabaseExport = useCallback(
@@ -401,6 +553,23 @@ function DatabaseList({
const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
+ function handleSemanticLayerDelete(item: ConnectionItem) {
+ SupersetClient.delete({
+ endpoint: `/api/v1/semantic_layer/${item.uuid}`,
+ }).then(
+ () => {
+ refreshData();
+ addSuccessToast(t('Deleted: %s', item.database_name));
+ setSlCurrentlyDeleting(null);
+ },
+ createErrorHandler(errMsg =>
+ addDangerToast(
+ t('There was an issue deleting %s: %s', item.database_name, errMsg),
+ ),
+ ),
+ );
+ }
+
const columns = useMemo(
() => [
{
@@ -413,7 +582,7 @@ function DatabaseList({
accessor: 'backend',
Header: t('Backend'),
size: 'xl',
- disableSortBy: true, // TODO: api support for sorting by 'backend'
+ disableSortBy: true,
id: 'backend',
},
{
@@ -427,13 +596,12 @@ function DatabaseList({
<span>{t('AQE')}</span>
</Tooltip>
),
- Cell: ({
- row: {
- original: { allow_run_async: allowRunAsync },
- },
- }: {
- row: { original: { allow_run_async: boolean } };
- }) => <BooleanDisplay value={allowRunAsync} />,
+ Cell: ({ row: { original } }: any) =>
+ original.source_type === 'semantic_layer' ? (
+ <span>–</span>
+ ) : (
+ <BooleanDisplay value={original.allow_run_async} />
+ ),
size: 'sm',
id: 'allow_run_async',
},
@@ -448,33 +616,36 @@ function DatabaseList({
<span>{t('DML')}</span>
</Tooltip>
),
- Cell: ({
- row: {
- original: { allow_dml: allowDML },
- },
- }: any) => <BooleanDisplay value={allowDML} />,
+ Cell: ({ row: { original } }: any) =>
+ original.source_type === 'semantic_layer' ? (
+ <span>–</span>
+ ) : (
+ <BooleanDisplay value={original.allow_dml} />
+ ),
size: 'sm',
id: 'allow_dml',
},
{
accessor: 'allow_file_upload',
Header: t('File upload'),
- Cell: ({
- row: {
- original: { allow_file_upload: allowFileUpload },
- },
- }: any) => <BooleanDisplay value={allowFileUpload} />,
+ Cell: ({ row: { original } }: any) =>
+ original.source_type === 'semantic_layer' ? (
+ <span>–</span>
+ ) : (
+ <BooleanDisplay value={original.allow_file_upload} />
+ ),
size: 'md',
id: 'allow_file_upload',
},
{
accessor: 'expose_in_sqllab',
Header: t('Expose in SQL Lab'),
- Cell: ({
- row: {
- original: { expose_in_sqllab: exposeInSqllab },
- },
- }: any) => <BooleanDisplay value={exposeInSqllab} />,
+ Cell: ({ row: { original } }: any) =>
+ original.source_type === 'semantic_layer' ? (
+ <span>–</span>
+ ) : (
+ <BooleanDisplay value={original.expose_in_sqllab} />
+ ),
size: 'md',
id: 'expose_in_sqllab',
},
@@ -494,6 +665,49 @@ function DatabaseList({
},
{
Cell: ({ row: { original } }: any) => {
+ const isSemanticLayer =
+ original.source_type === 'semantic_layer';
+
+ if (isSemanticLayer) {
+ if (!canEdit && !canDelete) return null;
+ return (
+ <Actions className="actions">
+ {canDelete && (
+ <Tooltip
+ id="delete-action-tooltip"
+ title={t('Delete')}
+ placement="bottom"
+ >
+ <span
+ role="button"
+ tabIndex={0}
+ className="action-button"
+ onClick={() => setSlCurrentlyDeleting(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={() => setSlCurrentlyEditing(original.uuid)}
+ >
+ <Icons.EditOutlined iconSize="l" />
+ </span>
+ </Tooltip>
+ )}
+ </Actions>
+ );
+ }
+
const handleEdit = () =>
handleDatabaseEditModal({ database: original, modalOpen: true });
const handleDelete = () => openDatabaseDeleteModal(original);
@@ -579,6 +793,12 @@ function DatabaseList({
hidden: !canEdit && !canDelete,
disableSortBy: true,
},
+ {
+ accessor: 'source_type',
+ hidden: true,
+ disableSortBy: true,
+ id: 'source_type',
+ },
{
accessor: QueryObjectColumns.ChangedBy,
hidden: true,
@@ -596,8 +816,8 @@ function DatabaseList({
],
);
- const filters: ListViewFilters = useMemo(
- () => [
+ const filters: ListViewFilters = useMemo(() => {
+ const baseFilters: ListViewFilters = [
{
Header: t('Name'),
key: 'search',
@@ -605,62 +825,83 @@ function DatabaseList({
input: 'search',
operator: FilterOperator.Contains,
},
- {
- Header: t('Expose in SQL Lab'),
- key: 'expose_in_sql_lab',
- id: 'expose_in_sqllab',
- input: 'select',
- operator: FilterOperator.Equals,
- unfilteredLabel: t('All'),
- selects: [
- { label: t('Yes'), value: true },
- { label: t('No'), value: false },
- ],
- },
- {
- Header: (
- <Tooltip
- id="allow-run-async-filter-header-tooltip"
- title={t('Asynchronous query execution')}
- placement="top"
- >
- <span>{t('AQE')}</span>
- </Tooltip>
- ),
- key: 'allow_run_async',
- id: 'allow_run_async',
+ ];
+
+ if (showSemanticLayers) {
+ baseFilters.push({
+ Header: t('Source'),
+ key: 'source_type',
+ id: 'source_type',
input: 'select',
operator: FilterOperator.Equals,
unfilteredLabel: t('All'),
selects: [
- { label: t('Yes'), value: true },
- { label: t('No'), value: false },
+ { label: t('Database'), value: 'database' },
+ { label: t('Semantic Layer'), value: 'semantic_layer' },
],
- },
- {
- Header: t('Modified by'),
- key: 'changed_by',
- id: 'changed_by',
- input: 'select',
- operator: FilterOperator.RelationOneMany,
- unfilteredLabel: t('All'),
- fetchSelects: createFetchRelated(
- 'database',
- 'changed_by',
- createErrorHandler(errMsg =>
- t(
- 'An error occurred while fetching dataset datasource values: %s',
- errMsg,
+ });
+ }
+
+ if (!showSemanticLayers) {
+ baseFilters.push(
+ {
+ Header: t('Expose in SQL Lab'),
+ key: 'expose_in_sql_lab',
+ id: 'expose_in_sqllab',
+ input: 'select',
+ operator: FilterOperator.Equals,
+ unfilteredLabel: t('All'),
+ selects: [
+ { label: t('Yes'), value: true },
+ { label: t('No'), value: false },
+ ],
+ },
+ {
+ Header: (
+ <Tooltip
+ id="allow-run-async-filter-header-tooltip"
+ title={t('Asynchronous query execution')}
+ placement="top"
+ >
+ <span>{t('AQE')}</span>
+ </Tooltip>
+ ),
+ key: 'allow_run_async',
+ id: 'allow_run_async',
+ input: 'select',
+ operator: FilterOperator.Equals,
+ unfilteredLabel: t('All'),
+ selects: [
+ { label: t('Yes'), value: true },
+ { label: t('No'), value: false },
+ ],
+ },
+ {
+ Header: t('Modified by'),
+ key: 'changed_by',
+ id: 'changed_by',
+ input: 'select',
+ operator: FilterOperator.RelationOneMany,
+ unfilteredLabel: t('All'),
+ fetchSelects: createFetchRelated(
+ 'database',
+ 'changed_by',
+ createErrorHandler(errMsg =>
+ t(
+ 'An error occurred while fetching dataset datasource values: %s',
+ errMsg,
+ ),
),
+ user,
),
- user,
- ),
- paginate: true,
- dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH },
- },
- ],
- [user],
- );
+ paginate: true,
+ dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH },
+ },
+ );
+ }
+
+ return baseFilters;
+ }, [showSemanticLayers]);
return (
<>
@@ -703,6 +944,48 @@ function DatabaseList({
allowedExtensions={COLUMNAR_EXTENSIONS}
type="columnar"
/>
+ <SemanticLayerModal
+ show={semanticLayerModalOpen}
+ onHide={() => {
+ setSemanticLayerModalOpen(false);
+ refreshData();
+ }}
+ addDangerToast={addDangerToast}
+ addSuccessToast={addSuccessToast}
+ />
+ <SemanticLayerModal
+ show={!!slCurrentlyEditing}
+ onHide={() => {
+ setSlCurrentlyEditing(null);
+ refreshData();
+ }}
+ addDangerToast={addDangerToast}
+ addSuccessToast={addSuccessToast}
+ semanticLayerUuid={slCurrentlyEditing ?? undefined}
+ />
+ {slCurrentlyDeleting && (
+ <DeleteModal
+ description={
+ <p>
+ {t('Are you sure you want to delete')}{' '}
+ <b>{slCurrentlyDeleting.database_name}</b>?
+ </p>
+ }
+ onConfirm={() => {
+ if (slCurrentlyDeleting) {
+ handleSemanticLayerDelete(slCurrentlyDeleting);
+ }
+ }}
+ onHide={() => setSlCurrentlyDeleting(null)}
+ open
+ title={
+ <ModalTitleWithIcon
+ icon={<Icons.DeleteOutlined />}
+ title={t('Delete Semantic Layer?')}
+ />
+ }
+ />
+ )}
{databaseCurrentlyDeleting && (
<DeleteModal
description={
diff --git a/superset/commands/semantic_layer/create.py b/superset/commands/semantic_layer/create.py
index 48ec0603c2a..8729f75a13b 100644
--- a/superset/commands/semantic_layer/create.py
+++ b/superset/commands/semantic_layer/create.py
@@ -16,6 +16,7 @@
# under the License.
from __future__ import annotations
+import json
import logging
from functools import partial
from typing import Any
@@ -48,6 +49,10 @@ class CreateSemanticLayerCommand(BaseCommand):
)
def run(self) -> Model:
self.validate()
+ if isinstance(self._properties.get("configuration"), dict):
+ self._properties["configuration"] = json.dumps(
+ self._properties["configuration"]
+ )
return SemanticLayerDAO.create(attributes=self._properties)
def validate(self) -> None:
diff --git a/superset/commands/semantic_layer/update.py b/superset/commands/semantic_layer/update.py
index 5242406af8c..66c02d568ab 100644
--- a/superset/commands/semantic_layer/update.py
+++ b/superset/commands/semantic_layer/update.py
@@ -16,6 +16,7 @@
# under the License.
from __future__ import annotations
+import json
import logging
from functools import partial
from typing import Any
@@ -87,6 +88,10 @@ class UpdateSemanticLayerCommand(BaseCommand):
def run(self) -> Model:
self.validate()
assert self._model
+ if isinstance(self._properties.get("configuration"), dict):
+ self._properties["configuration"] = json.dumps(
+ self._properties["configuration"]
+ )
return SemanticLayerDAO.update(self._model, attributes=self._properties)
def validate(self) -> None:
diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py
index eb10205106a..1b4d5c6db71 100644
--- a/superset/semantic_layers/api.py
+++ b/superset/semantic_layers/api.py
@@ -19,12 +19,14 @@ from __future__ import annotations
import logging
from typing import Any
-from flask import request, Response
-from flask_appbuilder.api import expose, protect, safe
+from flask import make_response, request, Response
+from flask_appbuilder.api import expose, protect, rison, safe
+from flask_appbuilder.api.schemas import get_list_schema
from flask_appbuilder.models.sqla.interface import SQLAInterface
from marshmallow import ValidationError
+from pydantic import ValidationError as PydanticValidationError
-from superset import event_logger
+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.exceptions import (
@@ -44,6 +46,7 @@ from superset.commands.semantic_layer.update import (
)
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP
from superset.daos.semantic_layer import SemanticLayerDAO
+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 (
@@ -52,6 +55,7 @@ from superset.semantic_layers.schemas import (
SemanticViewPutSchema,
)
from superset.superset_typing import FlaskResponse
+from superset.utils import json
from superset.views.base_api import (
BaseSupersetApi,
BaseSupersetModelRestApi,
@@ -63,15 +67,93 @@ logger = logging.getLogger(__name__)
def _serialize_layer(layer: SemanticLayer) -> dict[str, Any]:
+ config = layer.configuration
+ if isinstance(config, str):
+ config = json.loads(config)
return {
"uuid": str(layer.uuid),
"name": layer.name,
"description": layer.description,
"type": layer.type,
"cache_timeout": layer.cache_timeout,
+ "configuration": config or {},
+ "changed_on_delta_humanized": layer.changed_on_delta_humanized(),
}
+def _infer_discriminators(
+ schema: dict[str, Any],
+ data: dict[str, Any],
+) -> dict[str, Any]:
+ """
+ Infer discriminator values for union fields when the frontend omits them.
+
+ Walks the schema's properties looking for discriminated unions (fields with a
+ ``discriminator.mapping``). For each one, tries to match the submitted data
+ against one of the variants by checking which variant's required fields are
+ present, then injects the discriminator value.
+ """
+ defs = schema.get("$defs", {})
+ for prop_name, prop_schema in schema.get("properties", {}).items():
+ value = data.get(prop_name)
+ if not isinstance(value, dict):
+ continue
+
+ # Find discriminated union via discriminator mapping
+ mapping = (
+ prop_schema.get("discriminator", {}).get("mapping")
+ if "discriminator" in prop_schema
+ else None
+ )
+ if not mapping:
+ continue
+
+ discriminator_field = prop_schema["discriminator"].get("propertyName")
+ if not discriminator_field or discriminator_field in value:
+ continue
+
+ # Try each variant: match by required fields present in the data
+ for disc_value, ref in mapping.items():
+ ref_name = ref.rsplit("/", 1)[-1] if "/" in ref else ref
+ variant_def = defs.get(ref_name, {})
+ required = set(variant_def.get("required", []))
+ # Exclude the discriminator itself from the check
+ required.discard(discriminator_field)
+ if required and required.issubset(value.keys()):
+ data = {
+ **data,
+ prop_name: {**value, discriminator_field: disc_value},
+ }
+ break
+
+ return data
+
+
+def _parse_partial_config(
+ cls: Any,
+ config: dict[str, Any],
+) -> Any:
+ """
+ Parse a partial configuration, handling discriminator inference and
+ falling back to lenient validation when strict parsing fails.
+ """
+ config_class = cls.configuration_class
+
+ # Infer discriminator values the frontend may have omitted
+ schema = config_class.model_json_schema()
+ config = _infer_discriminators(schema, config)
+
+ try:
+ return config_class.model_validate(config)
+ except (PydanticValidationError, ValueError):
+ pass
+
+ try:
+ return config_class.model_validate(config, context={"partial": True})
+ except (PydanticValidationError, ValueError):
+ return None
+
+
class SemanticViewRestApi(BaseSupersetModelRestApi):
datamodel = SQLAInterface(SemanticView)
@@ -223,13 +305,18 @@ class SemanticLayerRestApi(BaseSupersetApi):
parsed_config = None
if config := body.get("configuration"):
- try:
- parsed_config = cls.from_configuration(config).configuration # type: ignore[attr-defined]
- except Exception: # pylint: disable=broad-except
- parsed_config = None
+ parsed_config = _parse_partial_config(cls, config)
- schema = cls.get_configuration_schema(parsed_config)
- return self.response(200, result=schema)
+ try:
+ schema = cls.get_configuration_schema(parsed_config)
+ except Exception: # pylint: disable=broad-except
+ # Connection or query failures during schema enrichment should not
+ # prevent the form from rendering — return the base schema instead.
+ schema = cls.get_configuration_schema(None)
+
+ 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()
@@ -436,6 +523,176 @@ class SemanticLayerRestApi(BaseSupersetApi):
)
return self.response_422(message=str(ex))
+ @expose("/connections/", methods=("GET",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @rison(get_list_schema)
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.connections",
+ log_to_statsd=False,
+ )
+ def connections(self, **kwargs: Any) -> FlaskResponse:
+ """List databases and semantic layers combined.
+ ---
+ get:
+ summary: List databases and semantic layers combined
+ parameters:
+ - in: query
+ name: q
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/get_list_schema'
+ responses:
+ 200:
+ description: Combined list of databases and semantic layers
+ 401:
+ $ref: '#/components/responses/401'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ args = kwargs.get("rison", {})
+ page = args.get("page", 0)
+ page_size = args.get("page_size", 25)
+ order_column = args.get("order_column", "changed_on")
+ order_direction = args.get("order_direction", "desc")
+ filters = args.get("filters", [])
+
+ source_type, name_filter = self._parse_connection_filters(filters)
+
+ if not is_feature_enabled("SEMANTIC_LAYERS"):
+ source_type = "database"
+
+ all_items = self._fetch_connection_items(source_type, name_filter)
+
+ sort_key = self._get_connection_sort_key(order_column)
+ all_items.sort(key=sort_key, reverse=order_direction == "desc") # type: ignore
+ total_count = len(all_items)
+
+ start = page * page_size
+ page_items = all_items[start : start + page_size]
+
+ result = [
+ self._serialize_database(obj)
+ if item_type == "database"
+ else self._serialize_semantic_layer(obj)
+ for item_type, obj in page_items
+ ]
+
+ return self.response(200, count=total_count, result=result)
+
+ @staticmethod
+ def _parse_connection_filters(
+ filters: list[dict[str, Any]],
+ ) -> tuple[str, str | None]:
+ """Parse filters into source_type and name_filter."""
+ source_type = "all"
+ name_filter = None
+ for f in filters:
+ if f.get("col") == "source_type":
+ source_type = f.get("value", "all")
+ elif f.get("col") == "database_name" and f.get("opr") == "ct":
+ name_filter = f.get("value")
+ return source_type, name_filter
+
+ @staticmethod
+ def _fetch_connection_items(
+ source_type: str,
+ name_filter: str | None,
+ ) -> list[tuple[str, Any]]:
+ """Fetch database and semantic layer items based on filters."""
+ db_items: list[tuple[str, Database]] = []
+ if source_type in ("all", "database"):
+ db_q = db.session.query(Database)
+ if name_filter:
+ db_q = db_q.filter(Database.database_name.ilike(f"%{name_filter}%"))
+ db_items = [("database", obj) for obj in db_q.all()]
+
+ sl_items: list[tuple[str, SemanticLayer]] = []
+ if source_type in ("all", "semantic_layer"):
+ sl_q = db.session.query(SemanticLayer)
+ if name_filter:
+ sl_q = sl_q.filter(SemanticLayer.name.ilike(f"%{name_filter}%"))
+ sl_items = [("semantic_layer", obj) for obj in sl_q.all()]
+
+ return db_items + sl_items # type: ignore
+
+ @staticmethod
+ def _get_connection_sort_key(order_column: str) -> Any:
+ """Return a sort key function for connection items."""
+
+ def _sort_key_changed_on(
+ item: tuple[str, Database | SemanticLayer],
+ ) -> float:
+ changed_on = item[1].changed_on
+ return changed_on.timestamp() if changed_on else 0.0
+
+ def _sort_key_name(
+ item: tuple[str, Database | SemanticLayer],
+ ) -> str:
+ obj = item[1]
+ raw = (
+ obj.database_name # type: ignore[union-attr]
+ if item[0] == "database"
+ else obj.name
+ )
+ return raw.lower()
+
+ sort_key_map = {
+ "changed_on_delta_humanized": _sort_key_changed_on,
+ "database_name": _sort_key_name,
+ }
+ return sort_key_map.get(order_column, _sort_key_changed_on)
+
+ @staticmethod
+ def _serialize_database(obj: Database) -> dict[str, Any]:
+ changed_by = obj.changed_by
+ return {
+ "source_type": "database",
+ "id": obj.id,
+ "uuid": str(obj.uuid),
+ "database_name": obj.database_name,
+ "backend": obj.backend,
+ "allow_run_async": obj.allow_run_async,
+ "allow_dml": obj.allow_dml,
+ "allow_file_upload": obj.allow_file_upload,
+ "expose_in_sqllab": obj.expose_in_sqllab,
+ "changed_on_delta_humanized": obj.changed_on_delta_humanized(),
+ "changed_by": {
+ "first_name": changed_by.first_name,
+ "last_name": changed_by.last_name,
+ }
+ if changed_by
+ else None,
+ }
+
+ @staticmethod
+ def _serialize_semantic_layer(obj: SemanticLayer) -> dict[str, Any]:
+ changed_by = obj.changed_by
+ sl_type = obj.type
+ cls = registry.get(sl_type)
+ type_name = cls.name if cls else sl_type
+ return {
+ "source_type": "semantic_layer",
+ "uuid": str(obj.uuid),
+ "database_name": obj.name,
+ "backend": type_name,
+ "sl_type": sl_type,
+ "description": obj.description,
+ "allow_run_async": None,
+ "allow_dml": None,
+ "allow_file_upload": None,
+ "expose_in_sqllab": None,
+ "changed_on_delta_humanized": obj.changed_on_delta_humanized(),
+ "changed_by": {
+ "first_name": changed_by.first_name,
+ "last_name": changed_by.last_name,
+ }
+ if changed_by
+ else None,
+ }
+
@expose("/", methods=("GET",))
@protect()
@safe
diff --git a/superset/static/service-worker.js b/superset/static/service-worker.js
index 43cb14a4894..f7ae786c023 100644
--- a/superset/static/service-worker.js
+++ b/superset/static/service-worker.js
@@ -1,27 +1,1471 @@
-/**
- * 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.
+/*
+ * ATTENTION: An "eval-source-map" devtool has been used.
+ * This devtool is neither made for production nor for readable output files.
+ * It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
+ * If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
+ * or disable the default devtool with "devtool: false".
+ * If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
+/******/ (() => { // webpackBootstrap
+/******/ "use strict";
+/******/ var __webpack_modules__ = ({
-// Minimal service worker for PWA file handling support
-self.addEventListener('install', event => {
- event.waitUntil(self.skipWaiting());
-});
+/***/ "./src/service-worker.ts"
+/*!*******************************!*\
+ !*** ./src/service-worker.ts ***!
+ \*******************************/
+() {
-self.addEventListener('activate', event => {
- event.waitUntil(self.clients.claim());
-});
+eval("{/**\n * Licensed to the Apache Software Foundation (ASF) under one\n * or more contributor license agreements. See the NOTICE file\n * distributed with this work for additional information\n * regarding copyright ownership. The ASF licenses this file\n * to you under the Apache License, Version 2.0 (the\n * \"License\"); you may not use this file except in compliance\n * with the License. You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2 [...]
+
+/***/ }
+
+/******/ });
+/************************************************************************/
+/******/ // The module cache
+/******/ var __webpack_module_cache__ = {};
+/******/
+/******/ // The require function
+/******/ function __webpack_require__(moduleId) {
+/******/ // Check if module is in cache
+/******/ var cachedModule = __webpack_module_cache__[moduleId];
+/******/ if (cachedModule !== undefined) {
+/******/ return cachedModule.exports;
+/******/ }
+/******/ // Check if module exists (development only)
+/******/ if (__webpack_modules__[moduleId] === undefined) {
+/******/ var e = new Error("Cannot find module '" + moduleId + "'");
+/******/ e.code = 'MODULE_NOT_FOUND';
+/******/ throw e;
+/******/ }
+/******/ // Create a new module (and put it into the cache)
+/******/ var module = __webpack_module_cache__[moduleId] = {
+/******/ id: moduleId,
+/******/ loaded: false,
+/******/ exports: {}
+/******/ };
+/******/
+/******/ // Execute the module function
+/******/ var execOptions = { id: moduleId, module: module, factory: __webpack_modules__[moduleId], require: __webpack_require__ };
+/******/ __webpack_require__.i.forEach(function(handler) { handler(execOptions); });
+/******/ module = execOptions.module;
+/******/ execOptions.factory.call(module.exports, module, module.exports, execOptions.require);
+/******/
+/******/ // Flag the module as loaded
+/******/ module.loaded = true;
+/******/
+/******/ // Return the exports of the module
+/******/ return module.exports;
+/******/ }
+/******/
+/******/ // expose the modules object (__webpack_modules__)
+/******/ __webpack_require__.m = __webpack_modules__;
+/******/
+/******/ // expose the module cache
+/******/ __webpack_require__.c = __webpack_module_cache__;
+/******/
+/******/ // expose the module execution interceptor
+/******/ __webpack_require__.i = [];
+/******/
+/************************************************************************/
+/******/ /* webpack/runtime/chunk loaded */
+/******/ (() => {
+/******/ var deferred = [];
+/******/ __webpack_require__.O = (result, chunkIds, fn, priority) => {
+/******/ if(chunkIds) {
+/******/ priority = priority || 0;
+/******/ for(var i = deferred.length; i > 0 && deferred[i - 1][2] > priority; i--) deferred[i] = deferred[i - 1];
+/******/ deferred[i] = [chunkIds, fn, priority];
+/******/ return;
+/******/ }
+/******/ var notFulfilled = Infinity;
+/******/ for (var i = 0; i < deferred.length; i++) {
+/******/ var [chunkIds, fn, priority] = deferred[i];
+/******/ var fulfilled = true;
+/******/ for (var j = 0; j < chunkIds.length; j++) {
+/******/ if ((priority & 1 === 0 || notFulfilled >= priority) && Object.keys(__webpack_require__.O).every((key) => (__webpack_require__.O[key](chunkIds[j])))) {
+/******/ chunkIds.splice(j--, 1);
+/******/ } else {
+/******/ fulfilled = false;
+/******/ if(priority < notFulfilled) notFulfilled = priority;
+/******/ }
+/******/ }
+/******/ if(fulfilled) {
+/******/ deferred.splice(i--, 1)
+/******/ var r = fn();
+/******/ if (r !== undefined) result = r;
+/******/ }
+/******/ }
+/******/ return result;
+/******/ };
+/******/ })();
+/******/
+/******/ /* webpack/runtime/compat get default export */
+/******/ (() => {
+/******/ // getDefaultExport function for compatibility with non-harmony modules
+/******/ __webpack_require__.n = (module) => {
+/******/ var getter = module && module.__esModule ?
+/******/ () => (module['default']) :
+/******/ () => (module);
+/******/ __webpack_require__.d(getter, { a: getter });
+/******/ return getter;
+/******/ };
+/******/ })();
+/******/
+/******/ /* webpack/runtime/create fake namespace object */
+/******/ (() => {
+/******/ var getProto = Object.getPrototypeOf ? (obj) => (Object.getPrototypeOf(obj)) : (obj) => (obj.__proto__);
+/******/ var leafPrototypes;
+/******/ // create a fake namespace object
+/******/ // mode & 1: value is a module id, require it
+/******/ // mode & 2: merge all properties of value into the ns
+/******/ // mode & 4: return value when already ns object
+/******/ // mode & 16: return value when it's Promise-like
+/******/ // mode & 8|1: behave like require
+/******/ __webpack_require__.t = function(value, mode) {
+/******/ if(mode & 1) value = this(value);
+/******/ if(mode & 8) return value;
+/******/ if(typeof value === 'object' && value) {
+/******/ if((mode & 4) && value.__esModule) return value;
+/******/ if((mode & 16) && typeof value.then === 'function') return value;
+/******/ }
+/******/ var ns = Object.create(null);
+/******/ __webpack_require__.r(ns);
+/******/ var def = {};
+/******/ leafPrototypes = leafPrototypes || [null, getProto({}), getProto([]), getProto(getProto)];
+/******/ for(var current = mode & 2 && value; (typeof current == 'object' || typeof current == 'function') && !~leafPrototypes.indexOf(current); current = getProto(current)) {
+/******/ Object.getOwnPropertyNames(current).forEach((key) => (def[key] = () => (value[key])));
+/******/ }
+/******/ def['default'] = () => (value);
+/******/ __webpack_require__.d(ns, def);
+/******/ return ns;
+/******/ };
+/******/ })();
+/******/
+/******/ /* webpack/runtime/define property getters */
+/******/ (() => {
+/******/ // define getter functions for harmony exports
+/******/ __webpack_require__.d = (exports, definition) => {
+/******/ for(var key in definition) {
+/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
+/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
+/******/ }
+/******/ }
+/******/ };
+/******/ })();
+/******/
+/******/ /* webpack/runtime/get javascript update chunk filename */
+/******/ (() => {
+/******/ // This function allow to reference all chunks
+/******/ __webpack_require__.hu = (chunkId) => {
+/******/ // return url for filenames based on template
+/******/ return "" + chunkId + "." + __webpack_require__.h() + ".hot-update.js";
+/******/ };
+/******/ })();
+/******/
+/******/ /* webpack/runtime/get update manifest filename */
+/******/ (() => {
+/******/ __webpack_require__.hmrF = () => ("service-worker." + __webpack_require__.h() + ".hot-update.json");
+/******/ })();
+/******/
+/******/ /* webpack/runtime/getFullHash */
+/******/ (() => {
+/******/ __webpack_require__.h = () => ("93138c79ea2b9d6fbac6")
+/******/ })();
+/******/
+/******/ /* webpack/runtime/harmony module decorator */
+/******/ (() => {
+/******/ __webpack_require__.hmd = (module) => {
+/******/ module = Object.create(module);
+/******/ if (!module.children) module.children = [];
+/******/ Object.defineProperty(module, 'exports', {
+/******/ enumerable: true,
+/******/ set: () => {
+/******/ throw new Error('ES Modules may not assign module.exports or exports.*, Use ESM export syntax, instead: ' + module.id);
+/******/ }
+/******/ });
+/******/ return module;
+/******/ };
+/******/ })();
+/******/
+/******/ /* webpack/runtime/hasOwnProperty shorthand */
+/******/ (() => {
+/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
+/******/ })();
+/******/
+/******/ /* webpack/runtime/load script */
+/******/ (() => {
+/******/ var inProgress = {};
+/******/ var dataWebpackPrefix = "superset:";
+/******/ // loadScript function to load a script via script tag
+/******/ __webpack_require__.l = (url, done, key, chunkId) => {
+/******/ if(inProgress[url]) { inProgress[url].push(done); return; }
+/******/ var script, needAttach;
+/******/ if(key !== undefined) {
+/******/ var scripts = document.getElementsByTagName("script");
+/******/ for(var i = 0; i < scripts.length; i++) {
+/******/ var s = scripts[i];
+/******/ if(s.getAttribute("src") == url || s.getAttribute("data-webpack") == dataWebpackPrefix + key) { script = s; break; }
+/******/ }
+/******/ }
+/******/ if(!script) {
+/******/ needAttach = true;
+/******/ script = document.createElement('script');
+/******/
+/******/ script.charset = 'utf-8';
+/******/ if (__webpack_require__.nc) {
+/******/ script.setAttribute("nonce", __webpack_require__.nc);
+/******/ }
+/******/ script.setAttribute("data-webpack", dataWebpackPrefix + key);
+/******/
+/******/ script.src = url;
+/******/ }
+/******/ inProgress[url] = [done];
+/******/ var onScriptComplete = (prev, event) => {
+/******/ // avoid mem leaks in IE.
+/******/ script.onerror = script.onload = null;
+/******/ clearTimeout(timeout);
+/******/ var doneFns = inProgress[url];
+/******/ delete inProgress[url];
+/******/ script.parentNode && script.parentNode.removeChild(script);
+/******/ doneFns && doneFns.forEach((fn) => (fn(event)));
+/******/ if(prev) return prev(event);
+/******/ }
+/******/ var timeout = setTimeout(onScriptComplete.bind(null, undefined, { type: 'timeout', target: script }), 120000);
+/******/ script.onerror = onScriptComplete.bind(null, script.onerror);
+/******/ script.onload = onScriptComplete.bind(null, script.onload);
+/******/ needAttach && document.head.appendChild(script);
+/******/ };
+/******/ })();
+/******/
+/******/ /* webpack/runtime/make namespace object */
+/******/ (() => {
+/******/ // define __esModule on exports
+/******/ __webpack_require__.r = (exports) => {
+/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
+/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
+/******/ }
+/******/ Object.defineProperty(exports, '__esModule', { value: true });
+/******/ };
+/******/ })();
+/******/
+/******/ /* webpack/runtime/node module decorator */
+/******/ (() => {
+/******/ __webpack_require__.nmd = (module) => {
+/******/ module.paths = [];
+/******/ if (!module.children) module.children = [];
+/******/ return module;
+/******/ };
+/******/ })();
+/******/
+/******/ /* webpack/runtime/sharing */
+/******/ (() => {
+/******/ __webpack_require__.S = {};
+/******/ var initPromises = {};
+/******/ var initTokens = {};
+/******/ __webpack_require__.I = (name, initScope) => {
+/******/ if(!initScope) initScope = [];
+/******/ // handling circular init calls
+/******/ var initToken = initTokens[name];
+/******/ if(!initToken) initToken = initTokens[name] = {};
+/******/ if(initScope.indexOf(initToken) >= 0) return;
+/******/ initScope.push(initToken);
+/******/ // only runs once
+/******/ if(initPromises[name]) return initPromises[name];
+/******/ // creates a new share scope if needed
+/******/ if(!__webpack_require__.o(__webpack_require__.S, name)) __webpack_require__.S[name] = {};
+/******/ // runs all init snippets from all modules reachable
+/******/ var scope = __webpack_require__.S[name];
+/******/ var warn = (msg) => {
+/******/ if (typeof console !== "undefined" && console.warn) console.warn(msg);
+/******/ };
+/******/ var uniqueName = "superset";
+/******/ var register = (name, version, factory, eager) => {
+/******/ var versions = scope[name] = scope[name] || {};
+/******/ var activeVersion = versions[version];
+/******/ if(!activeVersion || (!activeVersion.loaded && (!eager != !activeVersion.eager ? eager : uniqueName > activeVersion.from))) versions[version] = { get: factory, from: uniqueName, eager: !!eager };
+/******/ };
+/******/ var initExternal = (id) => {
+/******/ var handleError = (err) => (warn("Initialization of sharing external failed: " + err));
+/******/ try {
+/******/ var module = __webpack_require__(id);
+/******/ if(!module) return;
+/******/ var initFn = (module) => (module && module.init && module.init(__webpack_require__.S[name], initScope))
+/******/ if(module.then) return promises.push(module.then(initFn, handleError));
+/******/ var initResult = initFn(module);
+/******/ if(initResult && initResult.then) return promises.push(initResult['catch'](handleError));
+/******/ } catch(err) { handleError(err); }
+/******/ }
+/******/ var promises = [];
+/******/ switch(name) {
+/******/ case "default": {
+/******/ register("antd", "5.27.6", () => (() => (__webpack_require__(/*! ./node_modules/antd/es/index.js */ "./node_modules/antd/es/index.js"))), 1);
+/******/ register("react-dom", "17.0.2", () => (() => (__webpack_require__(/*! ./node_modules/react-dom/index.js */ "./node_modules/react-dom/index.js"))), 1);
+/******/ register("react", "17.0.2", () => (() => (__webpack_require__(/*! ./node_modules/react/index.js */ "./node_modules/react/index.js"))), 1);
+/******/ }
+/******/ break;
+/******/ }
+/******/ if(!promises.length) return initPromises[name] = 1;
+/******/ return initPromises[name] = Promise.all(promises).then(() => (initPromises[name] = 1));
+/******/ };
+/******/ })();
+/******/
+/******/ /* webpack/runtime/hot module replacement */
+/******/ (() => {
+/******/ var currentModuleData = {};
+/******/ var installedModules = __webpack_require__.c;
+/******/
+/******/ // module and require creation
+/******/ var currentChildModule;
+/******/ var currentParents = [];
+/******/
+/******/ // status
+/******/ var registeredStatusHandlers = [];
+/******/ var currentStatus = "idle";
+/******/
+/******/ // while downloading
+/******/ var blockingPromises = 0;
+/******/ var blockingPromisesWaiting = [];
+/******/
+/******/ // The update info
+/******/ var currentUpdateApplyHandlers;
+/******/ var queuedInvalidatedModules;
+/******/
+/******/ __webpack_require__.hmrD = currentModuleData;
+/******/
+/******/ __webpack_require__.i.push(function (options) {
+/******/ var module = options.module;
+/******/ var require = createRequire(options.require, options.id);
+/******/ module.hot = createModuleHotObject(options.id, module);
+/******/ module.parents = currentParents;
+/******/ module.children = [];
+/******/ currentParents = [];
+/******/ options.require = require;
+/******/ });
+/******/
+/******/ __webpack_require__.hmrC = {};
+/******/ __webpack_require__.hmrI = {};
+/******/
+/******/ function createRequire(require, moduleId) {
+/******/ var me = installedModules[moduleId];
+/******/ if (!me) return require;
+/******/ var fn = function (request) {
+/******/ if (me.hot.active) {
+/******/ if (installedModules[request]) {
+/******/ var parents = installedModules[request].parents;
+/******/ if (parents.indexOf(moduleId) === -1) {
+/******/ parents.push(moduleId);
+/******/ }
+/******/ } else {
+/******/ currentParents = [moduleId];
+/******/ currentChildModule = request;
+/******/ }
+/******/ if (me.children.indexOf(request) === -1) {
+/******/ me.children.push(request);
+/******/ }
+/******/ } else {
+/******/ console.warn(
+/******/ "[HMR] unexpected require(" +
+/******/ request +
+/******/ ") from disposed module " +
+/******/ moduleId
+/******/ );
+/******/ currentParents = [];
+/******/ }
+/******/ return require(request);
+/******/ };
+/******/ var createPropertyDescriptor = function (name) {
+/******/ return {
+/******/ configurable: true,
+/******/ enumerable: true,
+/******/ get: function () {
+/******/ return require[name];
+/******/ },
+/******/ set: function (value) {
+/******/ require[name] = value;
+/******/ }
+/******/ };
+/******/ };
+/******/ for (var name in require) {
+/******/ if (Object.prototype.hasOwnProperty.call(require, name) && name !== "e") {
+/******/ Object.defineProperty(fn, name, createPropertyDescriptor(name));
+/******/ }
+/******/ }
+/******/ fn.e = function (chunkId, fetchPriority) {
+/******/ return trackBlockingPromise(require.e(chunkId, fetchPriority));
+/******/ };
+/******/ return fn;
+/******/ }
+/******/
+/******/ function createModuleHotObject(moduleId, me) {
+/******/ var _main = currentChildModule !== moduleId;
+/******/ var hot = {
+/******/ // private stuff
+/******/ _acceptedDependencies: {},
+/******/ _acceptedErrorHandlers: {},
+/******/ _declinedDependencies: {},
+/******/ _selfAccepted: false,
+/******/ _selfDeclined: false,
+/******/ _selfInvalidated: false,
+/******/ _disposeHandlers: [],
+/******/ _main: _main,
+/******/ _requireSelf: function () {
+/******/ currentParents = me.parents.slice();
+/******/ currentChildModule = _main ? undefined : moduleId;
+/******/ __webpack_require__(moduleId);
+/******/ },
+/******/
+/******/ // Module API
+/******/ active: true,
+/******/ accept: function (dep, callback, errorHandler) {
+/******/ if (dep === undefined) hot._selfAccepted = true;
+/******/ else if (typeof dep === "function") hot._selfAccepted = dep;
+/******/ else if (typeof dep === "object" && dep !== null) {
+/******/ for (var i = 0; i < dep.length; i++) {
+/******/ hot._acceptedDependencies[dep[i]] = callback || function () {};
+/******/ hot._acceptedErrorHandlers[dep[i]] = errorHandler;
+/******/ }
+/******/ } else {
+/******/ hot._acceptedDependencies[dep] = callback || function () {};
+/******/ hot._acceptedErrorHandlers[dep] = errorHandler;
+/******/ }
+/******/ },
+/******/ decline: function (dep) {
+/******/ if (dep === undefined) hot._selfDeclined = true;
+/******/ else if (typeof dep === "object" && dep !== null)
+/******/ for (var i = 0; i < dep.length; i++)
+/******/ hot._declinedDependencies[dep[i]] = true;
+/******/ else hot._declinedDependencies[dep] = true;
+/******/ },
+/******/ dispose: function (callback) {
+/******/ hot._disposeHandlers.push(callback);
+/******/ },
+/******/ addDisposeHandler: function (callback) {
+/******/ hot._disposeHandlers.push(callback);
+/******/ },
+/******/ removeDisposeHandler: function (callback) {
+/******/ var idx = hot._disposeHandlers.indexOf(callback);
+/******/ if (idx >= 0) hot._disposeHandlers.splice(idx, 1);
+/******/ },
+/******/ invalidate: function () {
+/******/ this._selfInvalidated = true;
+/******/ switch (currentStatus) {
+/******/ case "idle":
+/******/ currentUpdateApplyHandlers = [];
+/******/ Object.keys(__webpack_require__.hmrI).forEach(function (key) {
+/******/ __webpack_require__.hmrI[key](
+/******/ moduleId,
+/******/ currentUpdateApplyHandlers
+/******/ );
+/******/ });
+/******/ setStatus("ready");
+/******/ break;
+/******/ case "ready":
+/******/ Object.keys(__webpack_require__.hmrI).forEach(function (key) {
+/******/ __webpack_require__.hmrI[key](
+/******/ moduleId,
+/******/ currentUpdateApplyHandlers
+/******/ );
+/******/ });
+/******/ break;
+/******/ case "prepare":
+/******/ case "check":
+/******/ case "dispose":
+/******/ case "apply":
+/******/ (queuedInvalidatedModules = queuedInvalidatedModules || []).push(
+/******/ moduleId
+/******/ );
+/******/ break;
+/******/ default:
+/******/ // ignore requests in error states
+/******/ break;
+/******/ }
+/******/ },
+/******/
+/******/ // Management API
+/******/ check: hotCheck,
+/******/ apply: hotApply,
+/******/ status: function (l) {
+/******/ if (!l) return currentStatus;
+/******/ registeredStatusHandlers.push(l);
+/******/ },
+/******/ addStatusHandler: function (l) {
+/******/ registeredStatusHandlers.push(l);
+/******/ },
+/******/ removeStatusHandler: function (l) {
+/******/ var idx = registeredStatusHandlers.indexOf(l);
+/******/ if (idx >= 0) registeredStatusHandlers.splice(idx, 1);
+/******/ },
+/******/
+/******/ // inherit from previous dispose call
+/******/ data: currentModuleData[moduleId]
+/******/ };
+/******/ currentChildModule = undefined;
+/******/ return hot;
+/******/ }
+/******/
+/******/ function setStatus(newStatus) {
+/******/ currentStatus = newStatus;
+/******/ var results = [];
+/******/
+/******/ for (var i = 0; i < registeredStatusHandlers.length; i++)
+/******/ results[i] = registeredStatusHandlers[i].call(null, newStatus);
+/******/
+/******/ return Promise.all(results).then(function () {});
+/******/ }
+/******/
+/******/ function unblock() {
+/******/ if (--blockingPromises === 0) {
+/******/ setStatus("ready").then(function () {
+/******/ if (blockingPromises === 0) {
+/******/ var list = blockingPromisesWaiting;
+/******/ blockingPromisesWaiting = [];
+/******/ for (var i = 0; i < list.length; i++) {
+/******/ list[i]();
+/******/ }
+/******/ }
+/******/ });
+/******/ }
+/******/ }
+/******/
+/******/ function trackBlockingPromise(promise) {
+/******/ switch (currentStatus) {
+/******/ case "ready":
+/******/ setStatus("prepare");
+/******/ /* fallthrough */
+/******/ case "prepare":
+/******/ blockingPromises++;
+/******/ promise.then(unblock, unblock);
+/******/ return promise;
+/******/ default:
+/******/ return promise;
+/******/ }
+/******/ }
+/******/
+/******/ function waitForBlockingPromises(fn) {
+/******/ if (blockingPromises === 0) return fn();
+/******/ return new Promise(function (resolve) {
+/******/ blockingPromisesWaiting.push(function () {
+/******/ resolve(fn());
+/******/ });
+/******/ });
+/******/ }
+/******/
+/******/ function hotCheck(applyOnUpdate) {
+/******/ if (currentStatus !== "idle") {
+/******/ throw new Error("check() is only allowed in idle status");
+/******/ }
+/******/ return setStatus("check")
+/******/ .then(__webpack_require__.hmrM)
+/******/ .then(function (update) {
+/******/ if (!update) {
+/******/ return setStatus(applyInvalidatedModules() ? "ready" : "idle").then(
+/******/ function () {
+/******/ return null;
+/******/ }
+/******/ );
+/******/ }
+/******/
+/******/ return setStatus("prepare").then(function () {
+/******/ var updatedModules = [];
+/******/ currentUpdateApplyHandlers = [];
+/******/
+/******/ return Promise.all(
+/******/ Object.keys(__webpack_require__.hmrC).reduce(function (
+/******/ promises,
+/******/ key
+/******/ ) {
+/******/ __webpack_require__.hmrC[key](
+/******/ update.c,
+/******/ update.r,
+/******/ update.m,
+/******/ promises,
+/******/ currentUpdateApplyHandlers,
+/******/ updatedModules,
+/******/ update.css
+/******/ );
+/******/ return promises;
+/******/ }, [])
+/******/ ).then(function () {
+/******/ return waitForBlockingPromises(function () {
+/******/ if (applyOnUpdate) {
+/******/ return internalApply(applyOnUpdate);
+/******/ }
+/******/ return setStatus("ready").then(function () {
+/******/ return updatedModules;
+/******/ });
+/******/ });
+/******/ });
+/******/ });
+/******/ });
+/******/ }
+/******/
+/******/ function hotApply(options) {
+/******/ if (currentStatus !== "ready") {
+/******/ return Promise.resolve().then(function () {
+/******/ throw new Error(
+/******/ "apply() is only allowed in ready status (state: " +
+/******/ currentStatus +
+/******/ ")"
+/******/ );
+/******/ });
+/******/ }
+/******/ return internalApply(options);
+/******/ }
+/******/
+/******/ function internalApply(options) {
+/******/ options = options || {};
+/******/
+/******/ applyInvalidatedModules();
+/******/
+/******/ var results = currentUpdateApplyHandlers.map(function (handler) {
+/******/ return handler(options);
+/******/ });
+/******/ currentUpdateApplyHandlers = undefined;
+/******/
+/******/ var errors = results
+/******/ .map(function (r) {
+/******/ return r.error;
+/******/ })
+/******/ .filter(Boolean);
+/******/
+/******/ if (errors.length > 0) {
+/******/ return setStatus("abort").then(function () {
+/******/ throw errors[0];
+/******/ });
+/******/ }
+/******/
+/******/ // Now in "dispose" phase
+/******/ var disposePromise = setStatus("dispose");
+/******/
+/******/ results.forEach(function (result) {
+/******/ if (result.dispose) result.dispose();
+/******/ });
+/******/
+/******/ // Now in "apply" phase
+/******/ var applyPromise = setStatus("apply");
+/******/
+/******/ var error;
+/******/ var reportError = function (err) {
+/******/ if (!error) error = err;
+/******/ };
+/******/
+/******/ var outdatedModules = [];
+/******/
+/******/ var onAccepted = function () {
+/******/ return Promise.all([disposePromise, applyPromise]).then(function () {
+/******/ // handle errors in accept handlers and self accepted module load
+/******/ if (error) {
+/******/ return setStatus("fail").then(function () {
+/******/ throw error;
+/******/ });
+/******/ }
+/******/
+/******/ if (queuedInvalidatedModules) {
+/******/ return internalApply(options).then(function (list) {
+/******/ outdatedModules.forEach(function (moduleId) {
+/******/ if (list.indexOf(moduleId) < 0) list.push(moduleId);
+/******/ });
+/******/ return list;
+/******/ });
+/******/ }
+/******/
+/******/ return setStatus("idle").then(function () {
+/******/ return outdatedModules;
+/******/ });
+/******/ });
+/******/ };
+/******/
+/******/ return Promise.all(
+/******/ results
+/******/ .filter(function (result) {
+/******/ return result.apply;
+/******/ })
+/******/ .map(function (result) {
+/******/ return result.apply(reportError);
+/******/ })
+/******/ )
+/******/ .then(function (applyResults) {
+/******/ applyResults.forEach(function (modules) {
+/******/ if (modules) {
+/******/ for (var i = 0; i < modules.length; i++) {
+/******/ outdatedModules.push(modules[i]);
+/******/ }
+/******/ }
+/******/ });
+/******/ })
+/******/ .then(onAccepted);
+/******/ }
+/******/
+/******/ function applyInvalidatedModules() {
+/******/ if (queuedInvalidatedModules) {
+/******/ if (!currentUpdateApplyHandlers) currentUpdateApplyHandlers = [];
+/******/ Object.keys(__webpack_require__.hmrI).forEach(function (key) {
+/******/ queuedInvalidatedModules.forEach(function (moduleId) {
+/******/ __webpack_require__.hmrI[key](
+/******/ moduleId,
+/******/ currentUpdateApplyHandlers
+/******/ );
+/******/ });
+/******/ });
+/******/ queuedInvalidatedModules = undefined;
+/******/ return true;
+/******/ }
+/******/ }
+/******/ })();
+/******/
+/******/ /* webpack/runtime/publicPath */
+/******/ (() => {
+/******/ __webpack_require__.p = "/static/assets/";
+/******/ })();
+/******/
+/******/ /* webpack/runtime/react refresh */
+/******/ (() => {
+/******/ const setup = (moduleId) => {
+/******/ const refresh = {
+/******/ moduleId: moduleId,
+/******/ register: (type, id) => {
+/******/ const typeId = moduleId + ' ' + id;
+/******/ refresh.runtime.register(type, typeId);
+/******/ },
+/******/ signature: () => (refresh.runtime.createSignatureFunctionForTransform()),
+/******/ runtime: {
+/******/ createSignatureFunctionForTransform: () => ((type) => (type)),
+/******/ register: x => {}
+/******/ },
+/******/ };
+/******/ return refresh;
+/******/ };
+/******/
+/******/ __webpack_require__.i.push((options) => {
+/******/ const originalFactory = options.factory;
+/******/ options.factory = function(moduleObject, moduleExports, webpackRequire) {
+/******/ const hotRequire = (request) => (webpackRequire(request));
+/******/ const createPropertyDescriptor = (name) => {
+/******/ return {
+/******/ configurable: true,
+/******/ enumerable: true,
+/******/ get: () => (webpackRequire[name]),
+/******/ set: (value) => {
+/******/ webpackRequire[name] = value;
+/******/ },
+/******/ };
+/******/ };
+/******/ for (const name in webpackRequire) {
+/******/ if (name === "$Refresh$") continue;
+/******/ if (Object.prototype.hasOwnProperty.call(webpackRequire, name)) {
+/******/ Object.defineProperty(hotRequire, name, createPropertyDescriptor(name));
+/******/ }
+/******/ }
+/******/ hotRequire.$Refresh$ = setup(options.id);
+/******/ originalFactory.call(this, moduleObject, moduleExports, hotRequire);
+/******/ };
+/******/ });
+/******/ })();
+/******/
+/******/ /* webpack/runtime/consumes */
+/******/ (() => {
+/******/ var parseVersion = (str) => {
+/******/ // see webpack/lib/util/semver.js for original code
+/******/ var p=p=>{return p.split(".").map(p=>{return+p==p?+p:p})},n=/^([^-+]+)?(?:-([^+]+))?(?:\+(.+))?$/.exec(str),r=n[1]?p(n[1]):[];return n[2]&&(r.length++,r.push.apply(r,p(n[2]))),n[3]&&(r.push([]),r.push.apply(r,p(n[3]))),r;
+/******/ }
+/******/ var versionLt = (a, b) => {
+/******/ // see webpack/lib/util/semver.js for original code
+/******/ a=parseVersion(a),b=parseVersion(b);for(var r=0;;){if(r>=a.length)return r<b.length&&"u"!=(typeof b[r])[0];var e=a[r],n=(typeof e)[0];if(r>=b.length)return"u"==n;var t=b[r],f=(typeof t)[0];if(n!=f)return"o"==n&&"n"==f||("s"==f||"u"==n);if("o"!=n&&"u"!=n&&e!=t)return e<t;r++}
+/******/ }
+/******/ var rangeToString = (range) => {
+/******/ // see webpack/lib/util/semver.js for original code
+/******/ var r=range[0],n="";if(1===range.length)return"*";if(r+.5){n+=0==r?">=":-1==r?"<":1==r?"^":2==r?"~":r>0?"=":"!=";for(var e=1,a=1;a<range.length;a++){e--,n+="u"==(typeof(t=range[a]))[0]?"-":(e>0?".":"")+(e=2,t)}return n}var g=[];for(a=1;a<range.length;a++){var t=range[a];g.push(0===t?"not("+o()+")":1===t?"("+o()+" || "+o()+")":2===t?g.pop()+" "+g.pop():rangeToString(t))}return o();function o(){return g.pop().replace(/^\((.+)\)$/,"$1")}
+/******/ }
+/******/ var satisfy = (range, version) => {
+/******/ // see webpack/lib/util/semver.js for original code
+/******/ if(0 in range){version=parseVersion(version);var e=range[0],r=e<0;r&&(e=-e-1);for(var n=0,i=1,a=!0;;i++,n++){var f,s,g=i<range.length?(typeof range[i])[0]:"";if(n>=version.length||"o"==(s=(typeof(f=version[n]))[0]))return!a||("u"==g?i>e&&!r:""==g!=r);if("u"==s){if(!a||"u"!=g)return!1}else if(a)if(g==s)if(i<=e){if(f!=range[i])return!1}else{if(r?f>range[i]:f<range[i])return!1;f!=range[i]&&(a=!1)}else if("s"!=g&&"n"!=g){if(r||i<=e)return!1;a=!1,i--}else{if(i<=e||s<g!=r)return!1; [...]
+/******/ }
+/******/ var exists = (scope, key) => {
+/******/ return scope && __webpack_require__.o(scope, key);
+/******/ }
+/******/ var get = (entry) => {
+/******/ entry.loaded = 1;
+/******/ return entry.get()
+/******/ };
+/******/ var eagerOnly = (versions) => {
+/******/ return Object.keys(versions).reduce((filtered, version) => {
+/******/ if (versions[version].eager) {
+/******/ filtered[version] = versions[version];
+/******/ }
+/******/ return filtered;
+/******/ }, {});
+/******/ };
+/******/ var findLatestVersion = (scope, key, eager) => {
+/******/ var versions = eager ? eagerOnly(scope[key]) : scope[key];
+/******/ var key = Object.keys(versions).reduce((a, b) => {
+/******/ return !a || versionLt(a, b) ? b : a;
+/******/ }, 0);
+/******/ return key && versions[key];
+/******/ };
+/******/ var findSatisfyingVersion = (scope, key, requiredVersion, eager) => {
+/******/ var versions = eager ? eagerOnly(scope[key]) : scope[key];
+/******/ var key = Object.keys(versions).reduce((a, b) => {
+/******/ if (!satisfy(requiredVersion, b)) return a;
+/******/ return !a || versionLt(a, b) ? b : a;
+/******/ }, 0);
+/******/ return key && versions[key]
+/******/ };
+/******/ var findSingletonVersionKey = (scope, key, eager) => {
+/******/ var versions = eager ? eagerOnly(scope[key]) : scope[key];
+/******/ return Object.keys(versions).reduce((a, b) => {
+/******/ return !a || (!versions[a].loaded && versionLt(a, b)) ? b : a;
+/******/ }, 0);
+/******/ };
+/******/ var getInvalidSingletonVersionMessage = (scope, key, version, requiredVersion) => {
+/******/ return "Unsatisfied version " + version + " from " + (version && scope[key][version].from) + " of shared singleton module " + key + " (required " + rangeToString(requiredVersion) + ")"
+/******/ };
+/******/ var getInvalidVersionMessage = (scope, scopeName, key, requiredVersion, eager) => {
+/******/ var versions = scope[key];
+/******/ return "No satisfying version (" + rangeToString(requiredVersion) + ")" + (eager ? " for eager consumption" : "") + " of shared module " + key + " found in shared scope " + scopeName + ".\n" +
+/******/ "Available versions: " + Object.keys(versions).map((key) => {
+/******/ return key + " from " + versions[key].from;
+/******/ }).join(", ");
+/******/ };
+/******/ var fail = (msg) => {
+/******/ throw new Error(msg);
+/******/ }
+/******/ var failAsNotExist = (scopeName, key) => {
+/******/ return fail("Shared module " + key + " doesn't exist in shared scope " + scopeName);
+/******/ }
+/******/ var warn = /*#__PURE__*/ (msg) => {
+/******/ if (typeof console !== "undefined" && console.warn) console.warn(msg);
+/******/ };
+/******/ var init = (fn) => (function(scopeName, key, eager, c, d) {
+/******/ var promise = __webpack_require__.I(scopeName);
+/******/ if (promise && promise.then && !eager) {
+/******/ return promise.then(fn.bind(fn, scopeName, __webpack_require__.S[scopeName], key, false, c, d));
+/******/ }
+/******/ return fn(scopeName, __webpack_require__.S[scopeName], key, eager, c, d);
+/******/ });
+/******/
+/******/ var useFallback = (scopeName, key, fallback) => {
+/******/ return fallback ? fallback() : failAsNotExist(scopeName, key);
+/******/ }
+/******/ var load = /*#__PURE__*/ init((scopeName, scope, key, eager, fallback) => {
+/******/ if (!exists(scope, key)) return useFallback(scopeName, key, fallback);
+/******/ return get(findLatestVersion(scope, key, eager));
+/******/ });
+/******/ var loadVersion = /*#__PURE__*/ init((scopeName, scope, key, eager, requiredVersion, fallback) => {
+/******/ if (!exists(scope, key)) return useFallback(scopeName, key, fallback);
+/******/ var satisfyingVersion = findSatisfyingVersion(scope, key, requiredVersion, eager);
+/******/ if (satisfyingVersion) return get(satisfyingVersion);
+/******/ warn(getInvalidVersionMessage(scope, scopeName, key, requiredVersion, eager))
+/******/ return get(findLatestVersion(scope, key, eager));
+/******/ });
+/******/ var loadStrictVersion = /*#__PURE__*/ init((scopeName, scope, key, eager, requiredVersion, fallback) => {
+/******/ if (!exists(scope, key)) return useFallback(scopeName, key, fallback);
+/******/ var satisfyingVersion = findSatisfyingVersion(scope, key, requiredVersion, eager);
+/******/ if (satisfyingVersion) return get(satisfyingVersion);
+/******/ if (fallback) return fallback();
+/******/ fail(getInvalidVersionMessage(scope, scopeName, key, requiredVersion, eager));
+/******/ });
+/******/ var loadSingleton = /*#__PURE__*/ init((scopeName, scope, key, eager, fallback) => {
+/******/ if (!exists(scope, key)) return useFallback(scopeName, key, fallback);
+/******/ var version = findSingletonVersionKey(scope, key, eager);
+/******/ return get(scope[key][version]);
+/******/ });
+/******/ var loadSingletonVersion = /*#__PURE__*/ init((scopeName, scope, key, eager, requiredVersion, fallback) => {
+/******/ if (!exists(scope, key)) return useFallback(scopeName, key, fallback);
+/******/ var version = findSingletonVersionKey(scope, key, eager);
+/******/ if (!satisfy(requiredVersion, version)) {
+/******/ warn(getInvalidSingletonVersionMessage(scope, key, version, requiredVersion));
+/******/ }
+/******/ return get(scope[key][version]);
+/******/ });
+/******/ var loadStrictSingletonVersion = /*#__PURE__*/ init((scopeName, scope, key, eager, requiredVersion, fallback) => {
+/******/ if (!exists(scope, key)) return useFallback(scopeName, key, fallback);
+/******/ var version = findSingletonVersionKey(scope, key, eager);
+/******/ if (!satisfy(requiredVersion, version)) {
+/******/ fail(getInvalidSingletonVersionMessage(scope, key, version, requiredVersion));
+/******/ }
+/******/ return get(scope[key][version]);
+/******/ });
+/******/ var installedModules = {};
+/******/ var moduleToHandlerMapping = {
+/******/ "webpack/sharing/consume/default/react-dom/react-dom": () => (loadSingletonVersion("default", "react-dom", true, [1,17,0,2], () => (() => (__webpack_require__(/*! react-dom */ "./node_modules/react-dom/index.js"))))),
+/******/ "webpack/sharing/consume/default/react/react": () => (loadSingletonVersion("default", "react", true, [1,17,0,2], () => (() => (__webpack_require__(/*! react */ "./node_modules/react/index.js")))))
+/******/ };
+/******/ var initialConsumes = ["webpack/sharing/consume/default/react-dom/react-dom","webpack/sharing/consume/default/react/react"];
+/******/ initialConsumes.forEach((id) => {
+/******/ __webpack_require__.m[id] = (module) => {
+/******/ // Handle case when module is used sync
+/******/ installedModules[id] = 0;
+/******/ delete __webpack_require__.c[id];
+/******/ var factory = moduleToHandlerMapping[id]();
+/******/ if(typeof factory !== "function") throw new Error("Shared module is not available for eager consumption: " + id);
+/******/ module.exports = factory();
+/******/ }
+/******/ });
+/******/ // no chunk loading of consumes
+/******/ })();
+/******/
+/******/ /* webpack/runtime/jsonp chunk loading */
+/******/ (() => {
+/******/ // no baseURI
+/******/
+/******/ // object to store loaded and loading chunks
+/******/ // undefined = chunk not loaded, null = chunk preloaded/prefetched
+/******/ // [resolve, reject, Promise] = chunk loading, 0 = chunk loaded
+/******/ var installedChunks = __webpack_require__.hmrS_jsonp = __webpack_require__.hmrS_jsonp || {
+/******/ "service-worker": 0,
+/******/ "webpack_sharing_consume_default_react-dom_react-dom-webpack_sharing_consume_default_react_rea-153fef": 0
+/******/ };
+/******/
+/******/ // no chunk on demand loading
+/******/
+/******/ // no prefetching
+/******/
+/******/ // no preloaded
+/******/
+/******/ var currentUpdatedModulesList;
+/******/ var waitingUpdateResolves = {};
+/******/ function loadUpdateChunk(chunkId, updatedModulesList) {
+/******/ currentUpdatedModulesList = updatedModulesList;
+/******/ return new Promise((resolve, reject) => {
+/******/ waitingUpdateResolves[chunkId] = resolve;
+/******/ // start update chunk loading
+/******/ var url = __webpack_require__.p + __webpack_require__.hu(chunkId);
+/******/ // create error before stack unwound to get useful stacktrace later
+/******/ var error = new Error();
+/******/ var loadingEnded = (event) => {
+/******/ if(waitingUpdateResolves[chunkId]) {
+/******/ waitingUpdateResolves[chunkId] = undefined
+/******/ var errorType = event && (event.type === 'load' ? 'missing' : event.type);
+/******/ var realSrc = event && event.target && event.target.src;
+/******/ error.message = 'Loading hot update chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
+/******/ error.name = 'ChunkLoadError';
+/******/ error.type = errorType;
+/******/ error.request = realSrc;
+/******/ reject(error);
+/******/ }
+/******/ };
+/******/ __webpack_require__.l(url, loadingEnded);
+/******/ });
+/******/ }
+/******/
+/******/ globalThis["webpackHotUpdatesuperset"] = (chunkId, moreModules, runtime) => {
+/******/ for(var moduleId in moreModules) {
+/******/ if(__webpack_require__.o(moreModules, moduleId)) {
+/******/ currentUpdate[moduleId] = moreModules[moduleId];
+/******/ if(currentUpdatedModulesList) currentUpdatedModulesList.push(moduleId);
+/******/ }
+/******/ }
+/******/ if(runtime) currentUpdateRuntime.push(runtime);
+/******/ if(waitingUpdateResolves[chunkId]) {
+/******/ waitingUpdateResolves[chunkId]();
+/******/ waitingUpdateResolves[chunkId] = undefined;
+/******/ }
+/******/ };
+/******/
+/******/ var currentUpdateChunks;
+/******/ var currentUpdate;
+/******/ var currentUpdateRemovedChunks;
+/******/ var currentUpdateRuntime;
+/******/ function applyHandler(options) {
+/******/ if (__webpack_require__.f) delete __webpack_require__.f.jsonpHmr;
+/******/ currentUpdateChunks = undefined;
+/******/ function getAffectedModuleEffects(updateModuleId) {
+/******/ var outdatedModules = [updateModuleId];
+/******/ var outdatedDependencies = {};
+/******/
+/******/ var queue = outdatedModules.map(function (id) {
+/******/ return {
+/******/ chain: [id],
+/******/ id: id
+/******/ };
+/******/ });
+/******/ while (queue.length > 0) {
+/******/ var queueItem = queue.pop();
+/******/ var moduleId = queueItem.id;
+/******/ var chain = queueItem.chain;
+/******/ var module = __webpack_require__.c[moduleId];
+/******/ if (
+/******/ !module ||
+/******/ (module.hot._selfAccepted && !module.hot._selfInvalidated)
+/******/ )
+/******/ continue;
+/******/ if (module.hot._selfDeclined) {
+/******/ return {
+/******/ type: "self-declined",
+/******/ chain: chain,
+/******/ moduleId: moduleId
+/******/ };
+/******/ }
+/******/ if (module.hot._main) {
+/******/ return {
+/******/ type: "unaccepted",
+/******/ chain: chain,
+/******/ moduleId: moduleId
+/******/ };
+/******/ }
+/******/ for (var i = 0; i < module.parents.length; i++) {
+/******/ var parentId = module.parents[i];
+/******/ var parent = __webpack_require__.c[parentId];
+/******/ if (!parent) continue;
+/******/ if (parent.hot._declinedDependencies[moduleId]) {
+/******/ return {
+/******/ type: "declined",
+/******/ chain: chain.concat([parentId]),
+/******/ moduleId: moduleId,
+/******/ parentId: parentId
+/******/ };
+/******/ }
+/******/ if (outdatedModules.indexOf(parentId) !== -1) continue;
+/******/ if (parent.hot._acceptedDependencies[moduleId]) {
+/******/ if (!outdatedDependencies[parentId])
+/******/ outdatedDependencies[parentId] = [];
+/******/ addAllToSet(outdatedDependencies[parentId], [moduleId]);
+/******/ continue;
+/******/ }
+/******/ delete outdatedDependencies[parentId];
+/******/ outdatedModules.push(parentId);
+/******/ queue.push({
+/******/ chain: chain.concat([parentId]),
+/******/ id: parentId
+/******/ });
+/******/ }
+/******/ }
+/******/
+/******/ return {
+/******/ type: "accepted",
+/******/ moduleId: updateModuleId,
+/******/ outdatedModules: outdatedModules,
+/******/ outdatedDependencies: outdatedDependencies
+/******/ };
+/******/ }
+/******/
+/******/ function addAllToSet(a, b) {
+/******/ for (var i = 0; i < b.length; i++) {
+/******/ var item = b[i];
+/******/ if (a.indexOf(item) === -1) a.push(item);
+/******/ }
+/******/ }
+/******/
+/******/ // at begin all updates modules are outdated
+/******/ // the "outdated" status can propagate to parents if they don't accept the children
+/******/ var outdatedDependencies = {};
+/******/ var outdatedModules = [];
+/******/ var appliedUpdate = {};
+/******/
+/******/ var warnUnexpectedRequire = function warnUnexpectedRequire(module) {
+/******/ console.warn(
+/******/ "[HMR] unexpected require(" + module.id + ") to disposed module"
+/******/ );
+/******/ };
+/******/
+/******/ for (var moduleId in currentUpdate) {
+/******/ if (__webpack_require__.o(currentUpdate, moduleId)) {
+/******/ var newModuleFactory = currentUpdate[moduleId];
+/******/ var result = newModuleFactory
+/******/ ? getAffectedModuleEffects(moduleId)
+/******/ : {
+/******/ type: "disposed",
+/******/ moduleId: moduleId
+/******/ };
+/******/ /** @type {Error|false} */
+/******/ var abortError = false;
+/******/ var doApply = false;
+/******/ var doDispose = false;
+/******/ var chainInfo = "";
+/******/ if (result.chain) {
+/******/ chainInfo = "\nUpdate propagation: " + result.chain.join(" -> ");
+/******/ }
+/******/ switch (result.type) {
+/******/ case "self-declined":
+/******/ if (options.onDeclined) options.onDeclined(result);
+/******/ if (!options.ignoreDeclined)
+/******/ abortError = new Error(
+/******/ "Aborted because of self decline: " +
+/******/ result.moduleId +
+/******/ chainInfo
+/******/ );
+/******/ break;
+/******/ case "declined":
+/******/ if (options.onDeclined) options.onDeclined(result);
+/******/ if (!options.ignoreDeclined)
+/******/ abortError = new Error(
+/******/ "Aborted because of declined dependency: " +
+/******/ result.moduleId +
+/******/ " in " +
+/******/ result.parentId +
+/******/ chainInfo
+/******/ );
+/******/ break;
+/******/ case "unaccepted":
+/******/ if (options.onUnaccepted) options.onUnaccepted(result);
+/******/ if (!options.ignoreUnaccepted)
+/******/ abortError = new Error(
+/******/ "Aborted because " + moduleId + " is not accepted" + chainInfo
+/******/ );
+/******/ break;
+/******/ case "accepted":
+/******/ if (options.onAccepted) options.onAccepted(result);
+/******/ doApply = true;
+/******/ break;
+/******/ case "disposed":
+/******/ if (options.onDisposed) options.onDisposed(result);
+/******/ doDispose = true;
+/******/ break;
+/******/ default:
+/******/ throw new Error("Unexception type " + result.type);
+/******/ }
+/******/ if (abortError) {
+/******/ return {
+/******/ error: abortError
+/******/ };
+/******/ }
+/******/ if (doApply) {
+/******/ appliedUpdate[moduleId] = newModuleFactory;
+/******/ addAllToSet(outdatedModules, result.outdatedModules);
+/******/ for (moduleId in result.outdatedDependencies) {
+/******/ if (__webpack_require__.o(result.outdatedDependencies, moduleId)) {
+/******/ if (!outdatedDependencies[moduleId])
+/******/ outdatedDependencies[moduleId] = [];
+/******/ addAllToSet(
+/******/ outdatedDependencies[moduleId],
+/******/ result.outdatedDependencies[moduleId]
+/******/ );
+/******/ }
+/******/ }
+/******/ }
+/******/ if (doDispose) {
+/******/ addAllToSet(outdatedModules, [result.moduleId]);
+/******/ appliedUpdate[moduleId] = warnUnexpectedRequire;
+/******/ }
+/******/ }
+/******/ }
+/******/ currentUpdate = undefined;
+/******/
+/******/ // Store self accepted outdated modules to require them later by the module system
+/******/ var outdatedSelfAcceptedModules = [];
+/******/ for (var j = 0; j < outdatedModules.length; j++) {
+/******/ var outdatedModuleId = outdatedModules[j];
+/******/ var module = __webpack_require__.c[outdatedModuleId];
+/******/ if (
+/******/ module &&
+/******/ (module.hot._selfAccepted || module.hot._main) &&
+/******/ // removed self-accepted modules should not be required
+/******/ appliedUpdate[outdatedModuleId] !== warnUnexpectedRequire &&
+/******/ // when called invalidate self-accepting is not possible
+/******/ !module.hot._selfInvalidated
+/******/ ) {
+/******/ outdatedSelfAcceptedModules.push({
+/******/ module: outdatedModuleId,
+/******/ require: module.hot._requireSelf,
+/******/ errorHandler: module.hot._selfAccepted
+/******/ });
+/******/ }
+/******/ }
+/******/
+/******/ var moduleOutdatedDependencies;
+/******/
+/******/ return {
+/******/ dispose: function () {
+/******/ currentUpdateRemovedChunks.forEach(function (chunkId) {
+/******/ delete installedChunks[chunkId];
+/******/ });
+/******/ currentUpdateRemovedChunks = undefined;
+/******/
+/******/ var idx;
+/******/ var queue = outdatedModules.slice();
+/******/ while (queue.length > 0) {
+/******/ var moduleId = queue.pop();
+/******/ var module = __webpack_require__.c[moduleId];
+/******/ if (!module) continue;
+/******/
+/******/ var data = {};
+/******/
+/******/ // Call dispose handlers
+/******/ var disposeHandlers = module.hot._disposeHandlers;
+/******/ for (j = 0; j < disposeHandlers.length; j++) {
+/******/ disposeHandlers[j].call(null, data);
+/******/ }
+/******/ __webpack_require__.hmrD[moduleId] = data;
+/******/
+/******/ // disable module (this disables requires from this module)
+/******/ module.hot.active = false;
+/******/
+/******/ // remove module from cache
+/******/ delete __webpack_require__.c[moduleId];
+/******/
+/******/ // when disposing there is no need to call dispose handler
+/******/ delete outdatedDependencies[moduleId];
+/******/
+/******/ // remove "parents" references from all children
+/******/ for (j = 0; j < module.children.length; j++) {
+/******/ var child = __webpack_require__.c[module.children[j]];
+/******/ if (!child) continue;
+/******/ idx = child.parents.indexOf(moduleId);
+/******/ if (idx >= 0) {
+/******/ child.parents.splice(idx, 1);
+/******/ }
+/******/ }
+/******/ }
+/******/
+/******/ // remove outdated dependency from module children
+/******/ var dependency;
+/******/ for (var outdatedModuleId in outdatedDependencies) {
+/******/ if (__webpack_require__.o(outdatedDependencies, outdatedModuleId)) {
+/******/ module = __webpack_require__.c[outdatedModuleId];
+/******/ if (module) {
+/******/ moduleOutdatedDependencies =
+/******/ outdatedDependencies[outdatedModuleId];
+/******/ for (j = 0; j < moduleOutdatedDependencies.length; j++) {
+/******/ dependency = moduleOutdatedDependencies[j];
+/******/ idx = module.children.indexOf(dependency);
+/******/ if (idx >= 0) module.children.splice(idx, 1);
+/******/ }
+/******/ }
+/******/ }
+/******/ }
+/******/ },
+/******/ apply: function (reportError) {
+/******/ var acceptPromises = [];
+/******/ // insert new code
+/******/ for (var updateModuleId in appliedUpdate) {
+/******/ if (__webpack_require__.o(appliedUpdate, updateModuleId)) {
+/******/ __webpack_require__.m[updateModuleId] = appliedUpdate[updateModuleId];
+/******/ }
+/******/ }
+/******/
+/******/ // run new runtime modules
+/******/ for (var i = 0; i < currentUpdateRuntime.length; i++) {
+/******/ currentUpdateRuntime[i](__webpack_require__);
+/******/ }
+/******/
+/******/ // call accept handlers
+/******/ for (var outdatedModuleId in outdatedDependencies) {
+/******/ if (__webpack_require__.o(outdatedDependencies, outdatedModuleId)) {
+/******/ var module = __webpack_require__.c[outdatedModuleId];
+/******/ if (module) {
+/******/ moduleOutdatedDependencies =
+/******/ outdatedDependencies[outdatedModuleId];
+/******/ var callbacks = [];
+/******/ var errorHandlers = [];
+/******/ var dependenciesForCallbacks = [];
+/******/ for (var j = 0; j < moduleOutdatedDependencies.length; j++) {
+/******/ var dependency = moduleOutdatedDependencies[j];
+/******/ var acceptCallback =
+/******/ module.hot._acceptedDependencies[dependency];
+/******/ var errorHandler =
+/******/ module.hot._acceptedErrorHandlers[dependency];
+/******/ if (acceptCallback) {
+/******/ if (callbacks.indexOf(acceptCallback) !== -1) continue;
+/******/ callbacks.push(acceptCallback);
+/******/ errorHandlers.push(errorHandler);
+/******/ dependenciesForCallbacks.push(dependency);
+/******/ }
+/******/ }
+/******/ for (var k = 0; k < callbacks.length; k++) {
+/******/ var result;
+/******/ try {
+/******/ result = callbacks[k].call(null, moduleOutdatedDependencies);
+/******/ } catch (err) {
+/******/ if (typeof errorHandlers[k] === "function") {
+/******/ try {
+/******/ errorHandlers[k](err, {
+/******/ moduleId: outdatedModuleId,
+/******/ dependencyId: dependenciesForCallbacks[k]
+/******/ });
+/******/ } catch (err2) {
+/******/ if (options.onErrored) {
+/******/ options.onErrored({
+/******/ type: "accept-error-handler-errored",
+/******/ moduleId: outdatedModuleId,
+/******/ dependencyId: dependenciesForCallbacks[k],
+/******/ error: err2,
+/******/ originalError: err
+/******/ });
+/******/ }
+/******/ if (!options.ignoreErrored) {
+/******/ reportError(err2);
+/******/ reportError(err);
+/******/ }
+/******/ }
+/******/ } else {
+/******/ if (options.onErrored) {
+/******/ options.onErrored({
+/******/ type: "accept-errored",
+/******/ moduleId: outdatedModuleId,
+/******/ dependencyId: dependenciesForCallbacks[k],
+/******/ error: err
+/******/ });
+/******/ }
+/******/ if (!options.ignoreErrored) {
+/******/ reportError(err);
+/******/ }
+/******/ }
+/******/ }
+/******/ if (result && typeof result.then === "function") {
+/******/ acceptPromises.push(result);
+/******/ }
+/******/ }
+/******/ }
+/******/ }
+/******/ }
+/******/
+/******/ var onAccepted = function () {
+/******/ // Load self accepted modules
+/******/ for (var o = 0; o < outdatedSelfAcceptedModules.length; o++) {
+/******/ var item = outdatedSelfAcceptedModules[o];
+/******/ var moduleId = item.module;
+/******/ try {
+/******/ item.require(moduleId);
+/******/ } catch (err) {
+/******/ if (typeof item.errorHandler === "function") {
+/******/ try {
+/******/ item.errorHandler(err, {
+/******/ moduleId: moduleId,
+/******/ module: __webpack_require__.c[moduleId]
+/******/ });
+/******/ } catch (err1) {
+/******/ if (options.onErrored) {
+/******/ options.onErrored({
+/******/ type: "self-accept-error-handler-errored",
+/******/ moduleId: moduleId,
+/******/ error: err1,
+/******/ originalError: err
+/******/ });
+/******/ }
+/******/ if (!options.ignoreErrored) {
+/******/ reportError(err1);
+/******/ reportError(err);
+/******/ }
+/******/ }
+/******/ } else {
+/******/ if (options.onErrored) {
+/******/ options.onErrored({
+/******/ type: "self-accept-errored",
+/******/ moduleId: moduleId,
+/******/ error: err
+/******/ });
+/******/ }
+/******/ if (!options.ignoreErrored) {
+/******/ reportError(err);
+/******/ }
+/******/ }
+/******/ }
+/******/ }
+/******/ };
+/******/
+/******/ return Promise.all(acceptPromises)
+/******/ .then(onAccepted)
+/******/ .then(function () {
+/******/ return outdatedModules;
+/******/ });
+/******/ }
+/******/ };
+/******/ }
+/******/ __webpack_require__.hmrI.jsonp = function (moduleId, applyHandlers) {
+/******/ if (!currentUpdate) {
+/******/ currentUpdate = {};
+/******/ currentUpdateRuntime = [];
+/******/ currentUpdateRemovedChunks = [];
+/******/ applyHandlers.push(applyHandler);
+/******/ }
+/******/ if (!__webpack_require__.o(currentUpdate, moduleId)) {
+/******/ currentUpdate[moduleId] = __webpack_require__.m[moduleId];
+/******/ }
+/******/ };
+/******/ __webpack_require__.hmrC.jsonp = function (
+/******/ chunkIds,
+/******/ removedChunks,
+/******/ removedModules,
+/******/ promises,
+/******/ applyHandlers,
+/******/ updatedModulesList
+/******/ ) {
+/******/ applyHandlers.push(applyHandler);
+/******/ currentUpdateChunks = {};
+/******/ currentUpdateRemovedChunks = removedChunks;
+/******/ currentUpdate = removedModules.reduce(function (obj, key) {
+/******/ obj[key] = false;
+/******/ return obj;
+/******/ }, {});
+/******/ currentUpdateRuntime = [];
+/******/ chunkIds.forEach(function (chunkId) {
+/******/ if (
+/******/ __webpack_require__.o(installedChunks, chunkId) &&
+/******/ installedChunks[chunkId] !== undefined
+/******/ ) {
+/******/ promises.push(loadUpdateChunk(chunkId, updatedModulesList));
+/******/ currentUpdateChunks[chunkId] = true;
+/******/ } else {
+/******/ currentUpdateChunks[chunkId] = false;
+/******/ }
+/******/ });
+/******/ if (__webpack_require__.f) {
+/******/ __webpack_require__.f.jsonpHmr = function (chunkId, promises) {
+/******/ if (
+/******/ currentUpdateChunks &&
+/******/ __webpack_require__.o(currentUpdateChunks, chunkId) &&
+/******/ !currentUpdateChunks[chunkId]
+/******/ ) {
+/******/ promises.push(loadUpdateChunk(chunkId));
+/******/ currentUpdateChunks[chunkId] = true;
+/******/ }
+/******/ };
+/******/ }
+/******/ };
+/******/
+/******/ __webpack_require__.hmrM = () => {
+/******/ if (typeof fetch === "undefined") throw new Error("No browser support: need fetch API");
+/******/ return fetch(__webpack_require__.p + __webpack_require__.hmrF()).then((response) => {
+/******/ if(response.status === 404) return; // no update available
+/******/ if(!response.ok) throw new Error("Failed to fetch update manifest " + response.statusText);
+/******/ return response.json();
+/******/ });
+/******/ };
+/******/
+/******/ __webpack_require__.O.j = (chunkId) => (installedChunks[chunkId] === 0);
+/******/
+/******/ // install a JSONP callback for chunk loading
+/******/ var webpackJsonpCallback = (parentChunkLoadingFunction, data) => {
+/******/ var [chunkIds, moreModules, runtime] = data;
+/******/ // add "moreModules" to the modules object,
+/******/ // then flag all "chunkIds" as loaded and fire callback
+/******/ var moduleId, chunkId, i = 0;
+/******/ if(chunkIds.some((id) => (installedChunks[id] !== 0))) {
+/******/ for(moduleId in moreModules) {
+/******/ if(__webpack_require__.o(moreModules, moduleId)) {
+/******/ __webpack_require__.m[moduleId] = moreModules[moduleId];
+/******/ }
+/******/ }
+/******/ if(runtime) var result = runtime(__webpack_require__);
+/******/ }
+/******/ if(parentChunkLoadingFunction) parentChunkLoadingFunction(data);
+/******/ for(;i < chunkIds.length; i++) {
+/******/ chunkId = chunkIds[i];
+/******/ if(__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {
+/******/ installedChunks[chunkId][0]();
+/******/ }
+/******/ installedChunks[chunkId] = 0;
+/******/ }
+/******/ return __webpack_require__.O(result);
+/******/ }
+/******/
+/******/ var chunkLoadingGlobal = globalThis["webpackChunksuperset"] = globalThis["webpackChunksuperset"] || [];
+/******/ chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));
+/******/ chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));
+/******/ })();
+/******/
+/************************************************************************/
+/******/
+/******/ // module cache are used so entry inlining is disabled
+/******/ // startup
+/******/ // Load entry module and return exports
+/******/ __webpack_require__.O(undefined, ["vendors","vendors-node_modules_rc-component_color-picker_es_index_js-node_modules_rc-component_mutate-o-484854","webpack_sharing_consume_default_react-dom_react-dom-webpack_sharing_consume_default_react_rea-153fef"], () => (__webpack_require__("./node_modules/@pmmmwh/react-refresh-webpack-plugin/client/ReactRefreshEntry.js")))
+/******/ __webpack_require__.O(undefined, ["vendors","vendors-node_modules_rc-component_color-picker_es_index_js-node_modules_rc-component_mutate-o-484854","webpack_sharing_consume_default_react-dom_react-dom-webpack_sharing_consume_default_react_rea-153fef"], () => (__webpack_require__("./node_modules/webpack-dev-server/client/index.js?protocol=ws%3A&hostname=0.0.0.0&port=0&pathname=%2Fws&logging=info&overlay=%7B%22errors%22%3Atrue%2C%22warnings%22%3Afalse%2C%22runtimeErrors%22%3A%22er [...]
+/******/ __webpack_require__.O(undefined, ["vendors","vendors-node_modules_rc-component_color-picker_es_index_js-node_modules_rc-component_mutate-o-484854","webpack_sharing_consume_default_react-dom_react-dom-webpack_sharing_consume_default_react_rea-153fef"], () => (__webpack_require__("./node_modules/webpack/hot/only-dev-server.js")))
+/******/ __webpack_require__.O(undefined, ["vendors","vendors-node_modules_rc-component_color-picker_es_index_js-node_modules_rc-component_mutate-o-484854","webpack_sharing_consume_default_react-dom_react-dom-webpack_sharing_consume_default_react_rea-153fef"], () => (__webpack_require__("./node_modules/@pmmmwh/react-refresh-webpack-plugin/client/ErrorOverlayEntry.js")))
+/******/ var __webpack_exports__ = __webpack_require__.O(undefined, ["vendors","vendors-node_modules_rc-component_color-picker_es_index_js-node_modules_rc-component_mutate-o-484854","webpack_sharing_consume_default_react-dom_react-dom-webpack_sharing_consume_default_react_rea-153fef"], () => (__webpack_require__("./src/service-worker.ts")))
+/******/ __webpack_exports__ = __webpack_require__.O(__webpack_exports__);
+/******/
+/******/ })()
+;
\ No newline at end of file
diff --git a/tests/unit_tests/commands/semantic_layer/create_test.py b/tests/unit_tests/commands/semantic_layer/create_test.py
index 3beceebc3b8..ca4bc0f82a2 100644
--- a/tests/unit_tests/commands/semantic_layer/create_test.py
+++ b/tests/unit_tests/commands/semantic_layer/create_test.py
@@ -51,7 +51,8 @@ def test_create_semantic_layer_success(mocker: MockerFixture) -> None:
result = CreateSemanticLayerCommand(data).run()
assert result == new_model
- dao.create.assert_called_once_with(attributes=data)
+ expected = {**data, "configuration": '{"account": "test"}'}
+ dao.create.assert_called_once_with(attributes=expected)
mock_cls.from_configuration.assert_called_once_with({"account": "test"})
diff --git a/tests/unit_tests/semantic_layers/api_test.py b/tests/unit_tests/semantic_layers/api_test.py
index 08e1a1ab796..c6482c0f139 100644
--- a/tests/unit_tests/semantic_layers/api_test.py
+++ b/tests/unit_tests/semantic_layers/api_test.py
@@ -353,11 +353,14 @@ def test_configuration_schema_with_partial_config(
mocker: MockerFixture,
) -> None:
"""Test POST /schema/configuration enriches schema with partial config."""
- mock_instance = MagicMock()
- mock_instance.configuration = {"account": "test"}
+ mock_config_obj = MagicMock()
mock_cls = MagicMock()
- mock_cls.from_configuration.return_value = mock_instance
+ mock_cls.configuration_class.model_json_schema.return_value = {
+ "type": "object",
+ "properties": {"account": {"type": "string"}},
+ }
+ mock_cls.configuration_class.model_validate.return_value = mock_config_obj
mock_cls.get_configuration_schema.return_value = {
"type": "object",
"properties": {"database": {"enum": ["db1", "db2"]}},
@@ -375,7 +378,7 @@ def test_configuration_schema_with_partial_config(
)
assert response.status_code == 200
- mock_cls.get_configuration_schema.assert_called_once_with({"account": "test"})
+ mock_cls.get_configuration_schema.assert_called_once_with(mock_config_obj)
@SEMANTIC_LAYERS_APP
@@ -385,8 +388,19 @@ def test_configuration_schema_with_invalid_partial_config(
mocker: MockerFixture,
) -> None:
"""Test /schema/configuration returns schema when partial config fails."""
+ from pydantic import ValidationError as PydanticValidationError
+
mock_cls = MagicMock()
- mock_cls.from_configuration.side_effect = ValueError("bad config")
+ mock_cls.configuration_class.model_json_schema.return_value = {
+ "type": "object",
+ "properties": {},
+ }
+ mock_cls.configuration_class.model_validate.side_effect = (
+ PydanticValidationError.from_exception_data(
+ title="test",
+ line_errors=[],
+ )
+ )
mock_cls.get_configuration_schema.return_value = {"type": "object"}
mocker.patch.dict(
@@ -832,6 +846,8 @@ def test_get_list_semantic_layers(
layer1.description = "First"
layer1.type = "snowflake"
layer1.cache_timeout = None
+ layer1.configuration = "{}"
+ layer1.changed_on_delta_humanized.return_value = "1 day ago"
layer2 = MagicMock()
layer2.uuid = uuid_lib.uuid4()
@@ -839,6 +855,8 @@ def test_get_list_semantic_layers(
layer2.description = None
layer2.type = "snowflake"
layer2.cache_timeout = 300
+ layer2.configuration = '{"account": "test"}'
+ layer2.changed_on_delta_humanized.return_value = "2 hours ago"
mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
mock_dao.find_all.return_value = [layer1, layer2]
@@ -851,6 +869,7 @@ def test_get_list_semantic_layers(
assert result[0]["name"] == "Layer 1"
assert result[1]["name"] == "Layer 2"
assert result[1]["cache_timeout"] == 300
+ assert result[1]["configuration"] == {"account": "test"}
@SEMANTIC_LAYERS_APP
@@ -883,6 +902,8 @@ def test_get_semantic_layer(
layer.description = "A layer"
layer.type = "snowflake"
layer.cache_timeout = 600
+ layer.configuration = '{"account": "test"}'
+ layer.changed_on_delta_humanized.return_value = "1 day ago"
mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
mock_dao.find_by_uuid.return_value = layer
@@ -895,6 +916,7 @@ def test_get_semantic_layer(
assert result["name"] == "My Layer"
assert result["type"] == "snowflake"
assert result["cache_timeout"] == 600
+ assert result["configuration"] == {"account": "test"}
@SEMANTIC_LAYERS_APP
@@ -910,3 +932,559 @@ def test_get_semantic_layer_not_found(
response = client.get(f"/api/v1/semantic_layer/{uuid_lib.uuid4()}")
assert response.status_code == 404
+
+
+@SEMANTIC_LAYERS_APP
+def test_serialize_layer_string_config(
+ client: Any,
+ full_api_access: None,
+ mocker: MockerFixture,
+) -> None:
+ """Test _serialize_layer handles string configuration (JSON)."""
+ layer = MagicMock()
+ layer.uuid = uuid_lib.uuid4()
+ layer.name = "Layer"
+ layer.description = None
+ layer.type = "snowflake"
+ layer.cache_timeout = None
+ layer.configuration = '{"account": "test"}'
+ layer.changed_on_delta_humanized.return_value = "1 day ago"
+
+ mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+ mock_dao.find_by_uuid.return_value = layer
+
+ response = client.get(f"/api/v1/semantic_layer/{layer.uuid}")
+
+ assert response.status_code == 200
+ assert response.json["result"]["configuration"] == {"account": "test"}
+
+
+@SEMANTIC_LAYERS_APP
+def test_serialize_layer_dict_config(
+ client: Any,
+ full_api_access: None,
+ mocker: MockerFixture,
+) -> None:
+ """Test _serialize_layer handles dict configuration."""
+ layer = MagicMock()
+ layer.uuid = uuid_lib.uuid4()
+ layer.name = "Layer"
+ layer.description = None
+ layer.type = "snowflake"
+ layer.cache_timeout = None
+ layer.configuration = {"account": "test"}
+ layer.changed_on_delta_humanized.return_value = "1 day ago"
+
+ mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+ mock_dao.find_by_uuid.return_value = layer
+
+ response = client.get(f"/api/v1/semantic_layer/{layer.uuid}")
+
+ assert response.status_code == 200
+ assert response.json["result"]["configuration"] == {"account": "test"}
+
+
+@SEMANTIC_LAYERS_APP
+def test_serialize_layer_none_config(
+ client: Any,
+ full_api_access: None,
+ mocker: MockerFixture,
+) -> None:
+ """Test _serialize_layer handles None configuration."""
+ layer = MagicMock()
+ layer.uuid = uuid_lib.uuid4()
+ layer.name = "Layer"
+ layer.description = None
+ layer.type = "snowflake"
+ layer.cache_timeout = None
+ layer.configuration = None
+ layer.changed_on_delta_humanized.return_value = "1 day ago"
+
+ mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+ mock_dao.find_by_uuid.return_value = layer
+
+ response = client.get(f"/api/v1/semantic_layer/{layer.uuid}")
+
+ assert response.status_code == 200
+ assert response.json["result"]["configuration"] == {}
+
+
+def test_infer_discriminators_injects_discriminator() -> None:
+ """Test _infer_discriminators injects discriminator values."""
+ from superset.semantic_layers.api import _infer_discriminators
+
+ schema = {
+ "$defs": {
+ "VariantA": {"required": ["disc", "field_a"]},
+ },
+ "properties": {
+ "auth": {
+ "discriminator": {
+ "propertyName": "disc",
+ "mapping": {"a": "#/$defs/VariantA"},
+ },
+ },
+ },
+ }
+ data = {"auth": {"field_a": "value"}}
+ result = _infer_discriminators(schema, data)
+ assert result["auth"]["disc"] == "a"
+
+
+def test_infer_discriminators_no_match() -> None:
+ """Test _infer_discriminators returns data unchanged when no match."""
+ from superset.semantic_layers.api import _infer_discriminators
+
+ schema = {
+ "$defs": {
+ "VariantA": {"required": ["disc", "field_a"]},
+ },
+ "properties": {
+ "auth": {
+ "discriminator": {
+ "propertyName": "disc",
+ "mapping": {"a": "#/$defs/VariantA"},
+ },
+ },
+ },
+ }
+ data = {"auth": {"other": "value"}}
+ result = _infer_discriminators(schema, data)
+ assert "disc" not in result["auth"]
+
+
+def test_infer_discriminators_skips_non_dict() -> None:
+ """Test _infer_discriminators skips non-dict values."""
+ from superset.semantic_layers.api import _infer_discriminators
+
+ schema = {
+ "$defs": {},
+ "properties": {"auth": {"discriminator": {"propertyName": "disc"}}},
+ }
+ data = {"auth": "a string"}
+ result = _infer_discriminators(schema, data)
+ assert result == data
+
+
+def test_infer_discriminators_skips_if_discriminator_present() -> None:
+ """Test _infer_discriminators skips when discriminator already set."""
+ from superset.semantic_layers.api import _infer_discriminators
+
+ schema = {
+ "$defs": {},
+ "properties": {
+ "auth": {
+ "discriminator": {
+ "propertyName": "disc",
+ "mapping": {"a": "#/$defs/VariantA"},
+ },
+ },
+ },
+ }
+ data = {"auth": {"disc": "a", "field_a": "value"}}
+ result = _infer_discriminators(schema, data)
+ assert result["auth"]["disc"] == "a"
+
+
+def test_infer_discriminators_no_discriminator() -> None:
+ """Test _infer_discriminators skips properties without discriminator."""
+ from superset.semantic_layers.api import _infer_discriminators
+
+ schema = {
+ "$defs": {},
+ "properties": {"auth": {"type": "object"}},
+ }
+ data = {"auth": {"key": "val"}}
+ result = _infer_discriminators(schema, data)
+ assert result == data
+
+
+def test_parse_partial_config_strict_success() -> None:
+ """Test _parse_partial_config returns config on strict validation."""
+ from superset.semantic_layers.api import _parse_partial_config
+
+ mock_cls = MagicMock()
+ mock_cls.configuration_class.model_json_schema.return_value = {
+ "properties": {},
+ }
+ validated = MagicMock()
+ mock_cls.configuration_class.model_validate.return_value = validated
+
+ result = _parse_partial_config(mock_cls, {"key": "val"})
+ assert result == validated
+
+
+def test_parse_partial_config_falls_back_to_partial() -> None:
+ """Test _parse_partial_config falls back to partial validation."""
+ from pydantic import ValidationError as PydanticValidationError
+
+ from superset.semantic_layers.api import _parse_partial_config
+
+ mock_cls = MagicMock()
+ mock_cls.configuration_class.model_json_schema.return_value = {
+ "properties": {},
+ }
+ partial_result = MagicMock()
+ mock_cls.configuration_class.model_validate.side_effect = [
+ PydanticValidationError.from_exception_data(title="test", line_errors=[]),
+ partial_result,
+ ]
+
+ result = _parse_partial_config(mock_cls, {"key": "val"})
+ assert result == partial_result
+
+
+def test_parse_partial_config_returns_none_on_failure() -> None:
+ """Test _parse_partial_config returns None when all validation fails."""
+ from pydantic import ValidationError as PydanticValidationError
+
+ from superset.semantic_layers.api import _parse_partial_config
+
+ mock_cls = MagicMock()
+ mock_cls.configuration_class.model_json_schema.return_value = {
+ "properties": {},
+ }
+ err = PydanticValidationError.from_exception_data(title="test", line_errors=[])
+ mock_cls.configuration_class.model_validate.side_effect = err
+
+ result = _parse_partial_config(mock_cls, {"key": "val"})
+ assert result is None
+
+
+@SEMANTIC_LAYERS_APP
+def test_configuration_schema_enrichment_error_fallback(
+ client: Any,
+ full_api_access: None,
+ mocker: MockerFixture,
+) -> None:
+ """Test configuration_schema falls back when enrichment raises."""
+ mock_cls = MagicMock()
+ mock_cls.configuration_class.model_json_schema.return_value = {
+ "properties": {},
+ }
+ mock_cls.configuration_class.model_validate.return_value = MagicMock()
+ mock_cls.get_configuration_schema.side_effect = [
+ RuntimeError("connection failed"),
+ {"type": "object"},
+ ]
+
+ mocker.patch.dict(
+ "superset.semantic_layers.api.registry",
+ {"snowflake": mock_cls},
+ clear=True,
+ )
+
+ response = client.post(
+ "/api/v1/semantic_layer/schema/configuration",
+ json={"type": "snowflake", "configuration": {"account": "test"}},
+ )
+
+ assert response.status_code == 200
+ assert response.json["result"] == {"type": "object"}
+ assert mock_cls.get_configuration_schema.call_count == 2
+
+
+@SEMANTIC_LAYERS_APP
+def test_connections_list(
+ client: Any,
+ full_api_access: None,
+ mocker: MockerFixture,
+) -> None:
+ """Test GET /connections/ returns combined database and layer list."""
+ from datetime import datetime
+
+ mock_db = MagicMock()
+ mock_db.id = 1
+ mock_db.uuid = uuid_lib.uuid4()
+ mock_db.database_name = "PostgreSQL"
+ mock_db.backend = "postgresql"
+ mock_db.allow_run_async = False
+ mock_db.allow_dml = False
+ mock_db.allow_file_upload = False
+ mock_db.expose_in_sqllab = True
+ mock_db.changed_on = datetime(2026, 1, 1)
+ mock_db.changed_on_delta_humanized.return_value = "1 month ago"
+ mock_db.changed_by = None
+
+ mock_layer = MagicMock()
+ mock_layer.uuid = uuid_lib.uuid4()
+ mock_layer.name = "My Layer"
+ mock_layer.type = "snowflake"
+ mock_layer.description = "A layer"
+ mock_layer.cache_timeout = None
+ mock_layer.changed_on = datetime(2026, 2, 1)
+ mock_layer.changed_on_delta_humanized.return_value = "1 day ago"
+ mock_layer.changed_by = None
+
+ mock_db_session = mocker.patch("superset.semantic_layers.api.db.session")
+ db_query = MagicMock()
+ db_query.all.return_value = [mock_db]
+ db_query.filter.return_value = db_query
+ sl_query = MagicMock()
+ sl_query.all.return_value = [mock_layer]
+ sl_query.filter.return_value = sl_query
+ mock_db_session.query.side_effect = [db_query, sl_query]
+
+ mock_cls = MagicMock()
+ mock_cls.name = "Snowflake"
+ mocker.patch.dict(
+ "superset.semantic_layers.api.registry",
+ {"snowflake": mock_cls},
+ clear=True,
+ )
+
+ mocker.patch(
+ "superset.semantic_layers.api.is_feature_enabled",
+ return_value=True,
+ )
+
+ response = client.get("/api/v1/semantic_layer/connections/")
+
+ assert response.status_code == 200
+ assert response.json["count"] == 2
+ result = response.json["result"]
+ assert len(result) == 2
+
+
+@SEMANTIC_LAYERS_APP
+def test_connections_database_only(
+ client: Any,
+ full_api_access: None,
+ mocker: MockerFixture,
+) -> None:
+ """Test GET /connections/ with semantic layers disabled."""
+ from datetime import datetime
+
+ mock_db = MagicMock()
+ mock_db.id = 1
+ mock_db.uuid = uuid_lib.uuid4()
+ mock_db.database_name = "PostgreSQL"
+ mock_db.backend = "postgresql"
+ mock_db.allow_run_async = False
+ mock_db.allow_dml = False
+ mock_db.allow_file_upload = False
+ mock_db.expose_in_sqllab = True
+ mock_db.changed_on = datetime(2026, 1, 1)
+ mock_db.changed_on_delta_humanized.return_value = "1 month ago"
+ mock_db.changed_by = MagicMock()
+ mock_db.changed_by.first_name = "John"
+ mock_db.changed_by.last_name = "Doe"
+
+ mock_db_session = mocker.patch("superset.semantic_layers.api.db.session")
+ db_query = MagicMock()
+ db_query.all.return_value = [mock_db]
+ mock_db_session.query.return_value = db_query
+
+ mocker.patch(
+ "superset.semantic_layers.api.is_feature_enabled",
+ return_value=False,
+ )
+
+ response = client.get("/api/v1/semantic_layer/connections/")
+
+ assert response.status_code == 200
+ assert response.json["count"] == 1
+ result = response.json["result"][0]
+ assert result["source_type"] == "database"
+ assert result["database_name"] == "PostgreSQL"
+ assert result["changed_by"]["first_name"] == "John"
+
+
+@SEMANTIC_LAYERS_APP
+def test_connections_name_filter(
+ client: Any,
+ full_api_access: None,
+ mocker: MockerFixture,
+) -> None:
+ """Test GET /connections/ with name filter."""
+ mock_db_session = mocker.patch("superset.semantic_layers.api.db.session")
+ db_query = MagicMock()
+ db_query.all.return_value = []
+ db_query.filter.return_value = db_query
+ sl_query = MagicMock()
+ sl_query.all.return_value = []
+ sl_query.filter.return_value = sl_query
+ mock_db_session.query.side_effect = [db_query, sl_query]
+
+ mocker.patch(
+ "superset.semantic_layers.api.is_feature_enabled",
+ return_value=True,
+ )
+
+ import prison as rison_lib
+
+ q = rison_lib.dumps(
+ {"filters": [{"col": "database_name", "opr": "ct", "value": "post"}]}
+ )
+ response = client.get(f"/api/v1/semantic_layer/connections/?q={q}")
+
+ assert response.status_code == 200
+ assert response.json["count"] == 0
+ # Verify filter was applied to both queries
+ db_query.filter.assert_called_once()
+ sl_query.filter.assert_called_once()
+
+
+@SEMANTIC_LAYERS_APP
+def test_connections_sort_by_name(
+ client: Any,
+ full_api_access: None,
+ mocker: MockerFixture,
+) -> None:
+ """Test GET /connections/ sorts by database_name."""
+ from datetime import datetime
+
+ mock_db = MagicMock()
+ mock_db.id = 1
+ mock_db.uuid = uuid_lib.uuid4()
+ mock_db.database_name = "Zebra DB"
+ mock_db.backend = "postgresql"
+ mock_db.allow_run_async = False
+ mock_db.allow_dml = False
+ mock_db.allow_file_upload = False
+ mock_db.expose_in_sqllab = True
+ mock_db.changed_on = datetime(2026, 1, 1)
+ mock_db.changed_on_delta_humanized.return_value = "1 month ago"
+ mock_db.changed_by = None
+
+ mock_layer = MagicMock()
+ mock_layer.uuid = uuid_lib.uuid4()
+ mock_layer.name = "Alpha Layer"
+ mock_layer.type = "snowflake"
+ mock_layer.description = None
+ mock_layer.cache_timeout = None
+ mock_layer.changed_on = datetime(2026, 2, 1)
+ mock_layer.changed_on_delta_humanized.return_value = "1 day ago"
+ mock_layer.changed_by = None
+
+ mock_db_session = mocker.patch("superset.semantic_layers.api.db.session")
+ db_query = MagicMock()
+ db_query.all.return_value = [mock_db]
+ sl_query = MagicMock()
+ sl_query.all.return_value = [mock_layer]
+ mock_db_session.query.side_effect = [db_query, sl_query]
+
+ mock_cls = MagicMock()
+ mock_cls.name = "Snowflake"
+ mocker.patch.dict(
+ "superset.semantic_layers.api.registry",
+ {"snowflake": mock_cls},
+ clear=True,
+ )
+
+ mocker.patch(
+ "superset.semantic_layers.api.is_feature_enabled",
+ return_value=True,
+ )
+
+ import prison as rison_lib
+
+ q = rison_lib.dumps({"order_column": "database_name", "order_direction": "asc"})
+ response = client.get(f"/api/v1/semantic_layer/connections/?q={q}")
+
+ assert response.status_code == 200
+ result = response.json["result"]
+ assert result[0]["database_name"] == "Alpha Layer"
+ assert result[1]["database_name"] == "Zebra DB"
+
+
+@SEMANTIC_LAYERS_APP
+def test_connections_source_type_filter(
+ client: Any,
+ full_api_access: None,
+ mocker: MockerFixture,
+) -> None:
+ """Test GET /connections/ with source_type filter."""
+ from datetime import datetime
+
+ mock_db = MagicMock()
+ mock_db.id = 1
+ mock_db.uuid = uuid_lib.uuid4()
+ mock_db.database_name = "PostgreSQL"
+ mock_db.backend = "postgresql"
+ mock_db.allow_run_async = False
+ mock_db.allow_dml = False
+ mock_db.allow_file_upload = False
+ mock_db.expose_in_sqllab = True
+ mock_db.changed_on = datetime(2026, 1, 1)
+ mock_db.changed_on_delta_humanized.return_value = "1 month ago"
+ mock_db.changed_by = None
+
+ mock_db_session = mocker.patch("superset.semantic_layers.api.db.session")
+ db_query = MagicMock()
+ db_query.all.return_value = [mock_db]
+ mock_db_session.query.return_value = db_query
+
+ mocker.patch(
+ "superset.semantic_layers.api.is_feature_enabled",
+ return_value=True,
+ )
+
+ import prison as rison_lib
+
+ q = rison_lib.dumps(
+ {"filters": [{"col": "source_type", "opr": "eq", "value": "database"}]}
+ )
+ response = client.get(f"/api/v1/semantic_layer/connections/?q={q}")
+
+ assert response.status_code == 200
+ assert response.json["count"] == 1
+ # Only one query call (for Database), not two
+ mock_db_session.query.assert_called_once()
+
+
+@SEMANTIC_LAYERS_APP
+def test_connections_source_type_semantic_layer_only(
+ client: Any,
+ full_api_access: None,
+ mocker: MockerFixture,
+) -> None:
+ """Test GET /connections/ with source_type=semantic_layer filter."""
+ from datetime import datetime
+
+ mock_layer = MagicMock()
+ mock_layer.uuid = uuid_lib.uuid4()
+ mock_layer.name = "My Layer"
+ mock_layer.type = "snowflake"
+ mock_layer.description = None
+ mock_layer.cache_timeout = None
+ mock_layer.changed_on = datetime(2026, 1, 1)
+ mock_layer.changed_on_delta_humanized.return_value = "1 day ago"
+ mock_layer.changed_by = None
+
+ mock_db_session = mocker.patch("superset.semantic_layers.api.db.session")
+ sl_query = MagicMock()
+ sl_query.all.return_value = [mock_layer]
+ mock_db_session.query.return_value = sl_query
+
+ mock_cls = MagicMock()
+ mock_cls.name = "Snowflake"
+ mocker.patch.dict(
+ "superset.semantic_layers.api.registry",
+ {"snowflake": mock_cls},
+ clear=True,
+ )
+
+ mocker.patch(
+ "superset.semantic_layers.api.is_feature_enabled",
+ return_value=True,
+ )
+
+ import prison as rison_lib
+
+ q = rison_lib.dumps(
+ {
+ "filters": [
+ {"col": "source_type", "opr": "eq", "value": "semantic_layer"},
+ {"col": "other_col", "opr": "eq", "value": "ignored"},
+ ]
+ }
+ )
+ response = client.get(f"/api/v1/semantic_layer/connections/?q={q}")
+
+ assert response.status_code == 200
+ assert response.json["count"] == 1
+ result = response.json["result"][0]
+ assert result["source_type"] == "semantic_layer"
+ # Only one query (SemanticLayer), no Database query
+ mock_db_session.query.assert_called_once()