diff --git a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx index e00eead4ba7..c1806fc19b5 100644 --- a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx +++ b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx @@ -21,24 +21,9 @@ import { t } from '@apache-superset/core/translation'; import { SupersetClient, getClientErrorObject } from '@superset-ui/core'; import { Input, Select, Button } 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, @@ -46,229 +31,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).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, -): 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)?.['x-dependsOn']; - const refreshing = - refreshingSchema && - Array.isArray(deps) && - areDependenciesSatisfied( - deps as string[], - (cfgData as Record) ?? {}, - ); - - if (!refreshing) { - return TextControl(props); - } - - const uischema = { - ...props.uischema, - options: { - ...props.uischema.options, - placeholderText: t('Loading...'), - inputProps: { suffix: }, - }, - }; - return TextControl({ ...props, uischema, enabled: false }); -} -const DynamicFieldRenderer = withJsonFormsControlProps(DynamicFieldControl); -const dynamicFieldEntry = { - tester: rankWith( - 3, - and( - isStringControl, - schemaMatches( - s => (s as Record)?.['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 = {}; - 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)['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 = { - type: 'Control', - scope: `#/properties/${key}`, - }; - if (typeof prop === 'object' && prop !== null) { - const options: Record = {}; - 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 { - const deps: Record = {}; - 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)['x-dependsOn']) - ) { - deps[key] = (prop as Record)['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, - data: Record, -): string { - const allDepKeys = new Set(); - for (const deps of Object.values(dynamicDeps)) { - for (const dep of deps) { - allDepKeys.add(dep); - } - } - const snapshot: Record = {}; - for (const key of [...allDepKeys].sort()) { - snapshot[key] = data[key]; - } - return JSON.stringify(snapshot); -} - interface SemanticLayerType { id: string; name: string; @@ -382,7 +157,7 @@ export default function SemanticLayerModal({ setSelectedType(layer.type); setFormData(layer.configuration ?? {}); setHasErrors(false); - // Fetch base schema (no configuration → no Snowflake connection) to + // Fetch base schema (no configuration -> no Snowflake connection) to // show the form immediately. The existing maybeRefreshSchema machinery // will trigger an enriched fetch in the background once deps are // satisfied, and DynamicFieldControl will show per-field spinners. diff --git a/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx new file mode 100644 index 00000000000..6b21e73e9ca --- /dev/null +++ b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx @@ -0,0 +1,257 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useEffect } from 'react'; +import { t } from '@apache-superset/core'; +import { Spin } from 'antd'; +import { withJsonFormsControlProps } from '@jsonforms/react'; +import type { + JsonSchema, + UISchemaElement, + ControlProps, +} from '@jsonforms/core'; +import { + rankWith, + and, + isStringControl, + formatIs, + schemaMatches, +} from '@jsonforms/core'; +import { + rendererRegistryEntries, + TextControl, +} from '@great-expectations/jsonforms-antd-renderers'; + +export const SCHEMA_REFRESH_DEBOUNCE_MS = 500; + +/** + * Custom renderer that renders `Input.Password` for fields with + * `format: "password"` in the JSON Schema (e.g. Pydantic `SecretStr`). + */ +function PasswordControl(props: ControlProps) { + const uischema = { + ...props.uischema, + options: { ...props.uischema.options, type: 'password' }, + }; + return TextControl({ ...props, uischema }); +} +const PasswordRenderer = withJsonFormsControlProps(PasswordControl); +const passwordEntry = { + tester: rankWith(3, and(isStringControl, formatIs('password'))), + renderer: PasswordRenderer, +}; + +/** + * Renderer for `const` properties (e.g. Pydantic discriminator fields). + * Renders nothing visually but ensures the const value is set in form data, + * so discriminated unions resolve correctly on the backend. + */ +function ConstControl({ data, handleChange, path, schema }: ControlProps) { + const constValue = (schema as Record).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)?.['x-dependsOn']; + const refreshing = + refreshingSchema && + Array.isArray(deps) && + areDependenciesSatisfied(deps as string[], (cfgData as Record) ?? {}); + + if (!refreshing) { + return TextControl(props); + } + + const uischema = { + ...props.uischema, + options: { + ...props.uischema.options, + placeholderText: t('Loading...'), + inputProps: { suffix: }, + }, + }; + return TextControl({ ...props, uischema, enabled: false }); +} +const DynamicFieldRenderer = withJsonFormsControlProps(DynamicFieldControl); +const dynamicFieldEntry = { + tester: rankWith( + 3, + and( + isStringControl, + schemaMatches( + s => (s as Record)?.['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 = {}; + 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)['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 = { + type: 'Control', + scope: `#/properties/${key}`, + }; + if (typeof prop === 'object' && prop !== null) { + const options: Record = {}; + 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 { + const deps: Record = {}; + 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)['x-dependsOn']) + ) { + deps[key] = (prop as Record)[ + '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, +): 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, + data: Record, +): string { + const allDepKeys = new Set(); + for (const deps of Object.values(dynamicDeps)) { + for (const dep of deps) { + allDepKeys.add(dep); + } + } + const snapshot: Record = {}; + 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('layer'); + const [layers, setLayers] = useState([]); + const [selectedLayerUuid, setSelectedLayerUuid] = useState( + null, + ); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + // Step 2: Configure (runtime schema) + const [runtimeSchema, setRuntimeSchema] = useState(null); + const [runtimeUiSchema, setRuntimeUiSchema] = useState< + UISchemaElement | undefined + >(undefined); + const [runtimeData, setRuntimeData] = useState>({}); + const [refreshingSchema, setRefreshingSchema] = useState(false); + const [hasRuntimeErrors, setHasRuntimeErrors] = useState(false); + const errorsRef = useRef([]); + const debounceTimerRef = useRef | null>(null); + const lastDepSnapshotRef = useRef(''); + const dynamicDepsRef = useRef>({}); + + // Step 3: Select views + const [availableViews, setAvailableViews] = useState([]); + const [selectedViews, setSelectedViews] = useState>(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) => { + 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) => { + 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) => { + 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; + 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 ( + } + width={modalWidth} + saveDisabled={saveDisabled} + saveText={saveText} + saveLoading={saving} + contentLoading={loading} + > + {step === 'layer' && ( + + +