/** * 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/translation'; import { styled } from '@apache-superset/core/theme'; import { SupersetClient } from '@superset-ui/core'; import { 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, } from 'src/components/Modal'; import { renderers, sanitizeSchema, buildUiSchema, getDynamicDependencies, areDependenciesSatisfied, serializeDependencyValues, SCHEMA_REFRESH_DEBOUNCE_MS, } from 'src/features/semanticLayers/jsonFormsHelpers'; interface SemanticLayerOption { uuid: string; name: string; } interface AvailableView { name: string; already_added: boolean; } const ModalContent = styled.div` padding: ${({ theme }) => theme.sizeUnit * 4}px; `; const LoadingContainer = styled.div` display: flex; justify-content: center; padding: ${({ theme }) => theme.sizeUnit * 4}px; `; const SectionLabel = styled.div` color: ${({ theme }) => theme.colorText}; font-size: ${({ theme }) => theme.fontSize}px; margin-top: ${({ theme }) => theme.sizeUnit}px; margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px; `; const VerticalFormFields = styled.div` margin-bottom: ${({ theme }) => theme.sizeUnit * 4}px; /* The antd renderer's VerticalLayout creates its own
— force flex-column so gap controls spacing between fields */ && form { display: flex; flex-direction: column; gap: ${({ theme }) => theme.sizeUnit * 4}px; } /* Reset antd default margins so gap controls all spacing */ && .ant-form-item { margin-bottom: 0; } /* Override ant-form-item-horizontal: stack label above control */ && .ant-form-item-row { flex-direction: column; align-items: stretch; } && .ant-form-item-label { text-align: left; max-width: 100%; flex: none; padding-bottom: ${({ theme }) => theme.sizeUnit}px; } && .ant-form-item-control { max-width: 100%; flex: auto; } && .ant-form-item-label > label { color: ${({ theme }) => theme.colorText}; font-size: ${({ theme }) => theme.fontSize}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) { // --- Layer --- const [layers, setLayers] = useState([]); const [selectedLayerUuid, setSelectedLayerUuid] = useState( null, ); const [loadingLayers, setLoadingLayers] = useState(false); // --- Runtime config --- const [runtimeSchema, setRuntimeSchema] = useState(null); const [runtimeUiSchema, setRuntimeUiSchema] = useState< UISchemaElement | undefined >(); const [runtimeData, setRuntimeData] = useState>({}); const [loadingRuntime, setLoadingRuntime] = useState(false); const [refreshingSchema, setRefreshingSchema] = useState(false); const errorsRef = useRef([]); const dynamicDepsRef = useRef>({}); const lastDepSnapshotRef = useRef(''); const schemaTimerRef = useRef | null>(null); // --- Views --- const [availableViews, setAvailableViews] = useState([]); const [selectedViewNames, setSelectedViewNames] = useState([]); const [loadingViews, setLoadingViews] = useState(false); const viewsTimerRef = useRef | null>(null); const lastViewsKeyRef = useRef(''); // --- Misc --- const [saving, setSaving] = useState(false); const fetchGenRef = useRef(0); // ========================================================================= // Fetch helpers // ========================================================================= const fetchLayers = async () => { setLoadingLayers(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 { setLoadingLayers(false); } }; const fetchViews = useCallback( async (uuid: string, rData: Record, gen: number) => { setLoadingViews(true); setAvailableViews([]); setSelectedViewNames([]); try { const { json } = await SupersetClient.post({ endpoint: `/api/v1/semantic_layer/${uuid}/views`, jsonPayload: { runtime_data: rData }, }); if (gen !== fetchGenRef.current) return; setAvailableViews(json.result ?? []); } catch { if (gen !== fetchGenRef.current) return; addDangerToast(t('An error occurred while fetching available views')); } finally { if (gen === fetchGenRef.current) setLoadingViews(false); } }, [addDangerToast], ); const applyRuntimeSchema = useCallback((rawSchema: JsonSchema) => { const schema = sanitizeSchema(rawSchema); setRuntimeSchema(schema); setRuntimeUiSchema(buildUiSchema(schema)); dynamicDepsRef.current = getDynamicDependencies(rawSchema); }, []); const scheduleFetchViews = useCallback( (uuid: string, data: Record) => { const key = JSON.stringify(data); if (key === lastViewsKeyRef.current) return; lastViewsKeyRef.current = key; if (viewsTimerRef.current) clearTimeout(viewsTimerRef.current); viewsTimerRef.current = setTimeout(() => { fetchViews(uuid, data, fetchGenRef.current); }, SCHEMA_REFRESH_DEBOUNCE_MS); }, [fetchViews], ); // ========================================================================= // Layer change — fetch runtime schema, clear downstream state // ========================================================================= const handleLayerChange = useCallback( async (uuid: string) => { fetchGenRef.current += 1; const gen = fetchGenRef.current; setSelectedLayerUuid(uuid); if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current); if (viewsTimerRef.current) clearTimeout(viewsTimerRef.current); setRuntimeSchema(null); setRuntimeUiSchema(undefined); setRuntimeData({}); errorsRef.current = []; dynamicDepsRef.current = {}; lastDepSnapshotRef.current = ''; setAvailableViews([]); setSelectedViewNames([]); lastViewsKeyRef.current = ''; setLoadingRuntime(true); try { const { json } = await SupersetClient.post({ endpoint: `/api/v1/semantic_layer/${uuid}/schema/runtime`, jsonPayload: {}, }); if (gen !== fetchGenRef.current) return; const schema = json.result; if ( !schema?.properties || Object.keys(schema.properties).length === 0 ) { // No runtime config needed — fetch views right away fetchViews(uuid, {}, gen); } else { applyRuntimeSchema(schema); } } catch { if (gen !== fetchGenRef.current) return; addDangerToast( t('An error occurred while fetching the runtime schema'), ); } finally { if (gen === fetchGenRef.current) setLoadingRuntime(false); } }, [applyRuntimeSchema, fetchViews, addDangerToast], ); // ========================================================================= // Runtime form change — refresh dynamic fields or auto-fetch views // ========================================================================= const handleRuntimeFormChange = useCallback( ({ data, errors, }: { data: Record; errors?: ErrorObject[]; }) => { setRuntimeData(data); errorsRef.current = errors ?? []; if (!selectedLayerUuid) return; const gen = fetchGenRef.current; // Dynamic deps changed → refresh schema (e.g. database → schema) const dynamicDeps = dynamicDepsRef.current; if (Object.keys(dynamicDeps).length > 0) { const hasSatisfiedDeps = Object.values(dynamicDeps).some(deps => areDependenciesSatisfied(deps, data), ); if (hasSatisfiedDeps) { const snapshot = serializeDependencyValues(dynamicDeps, data); if (snapshot !== lastDepSnapshotRef.current) { lastDepSnapshotRef.current = snapshot; // Config is changing — clear views setAvailableViews([]); setSelectedViewNames([]); lastViewsKeyRef.current = ''; if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current); const uuid = selectedLayerUuid; schemaTimerRef.current = setTimeout(async () => { setRefreshingSchema(true); try { const { json } = await SupersetClient.post({ endpoint: `/api/v1/semantic_layer/${uuid}/schema/runtime`, jsonPayload: { runtime_data: data }, }); if (gen !== fetchGenRef.current) return; applyRuntimeSchema(json.result); } catch { // Silent fail on refresh — form still works } finally { if (gen === fetchGenRef.current) setRefreshingSchema(false); } }, SCHEMA_REFRESH_DEBOUNCE_MS); return; } } } // No schema refresh needed — fetch views if form is valid if (errorsRef.current.length === 0) { scheduleFetchViews(selectedLayerUuid, data); } }, [selectedLayerUuid, applyRuntimeSchema, scheduleFetchViews], ); // After a schema refresh settles, JSON Forms re-validates and fires // onChange → handleRuntimeFormChange handles view fetching. As a fallback // (in case onChange doesn't fire), try once refreshingSchema flips false. const prevRefreshingRef = useRef(false); useEffect(() => { if (prevRefreshingRef.current && !refreshingSchema && selectedLayerUuid) { const timer = setTimeout(() => { if (errorsRef.current.length === 0) { scheduleFetchViews(selectedLayerUuid, runtimeData); } }, 100); prevRefreshingRef.current = false; return () => clearTimeout(timer); } prevRefreshingRef.current = refreshingSchema; return undefined; }, [refreshingSchema, selectedLayerUuid, runtimeData, scheduleFetchViews]); // ========================================================================= // Modal open / close // ========================================================================= useEffect(() => { if (show) { fetchLayers(); } else { fetchGenRef.current += 1; if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current); if (viewsTimerRef.current) clearTimeout(viewsTimerRef.current); setLayers([]); setSelectedLayerUuid(null); setLoadingLayers(false); setRuntimeSchema(null); setRuntimeUiSchema(undefined); setRuntimeData({}); setLoadingRuntime(false); setRefreshingSchema(false); errorsRef.current = []; dynamicDepsRef.current = {}; lastDepSnapshotRef.current = ''; setAvailableViews([]); setSelectedViewNames([]); setLoadingViews(false); setSaving(false); lastViewsKeyRef.current = ''; } }, [show]); // eslint-disable-line react-hooks/exhaustive-deps // ========================================================================= // Save // ========================================================================= const newViewCount = availableViews.filter( v => selectedViewNames.includes(v.name) && !v.already_added, ).length; const handleSave = async () => { if (!selectedLayerUuid || newViewCount === 0) return; setSaving(true); try { const viewsToCreate = availableViews .filter(v => selectedViewNames.includes(v.name) && !v.already_added) .map(v => ({ name: v.name, semantic_layer_uuid: selectedLayerUuid, configuration: runtimeData, })); const { json } = await SupersetClient.post({ endpoint: '/api/v1/semantic_view/', jsonPayload: { views: viewsToCreate }, }); const created = Array.isArray(json?.result?.created) ? json.result.created : []; const errors = Array.isArray(json?.result?.errors) ? json.result.errors : []; if (created.length > 0) { addSuccessToast(t('%s semantic view(s) added', created.length)); } if (errors.length > 0) { const failedNames = errors .map((error: { name?: string }) => error?.name) .filter((name: string | undefined): name is string => !!name); addDangerToast( failedNames.length > 0 ? t( '%s semantic view(s) failed to add: %s', errors.length, failedNames.join(', '), ) : t('%s semantic view(s) failed to add', errors.length), ); } if (created.length > 0 && errors.length === 0) { onSuccess(); onHide(); } else if (errors.length === 0) { addDangerToast(t('An error occurred while adding semantic views')); } } catch { addDangerToast(t('An error occurred while adding semantic views')); } finally { setSaving(false); } }; // ========================================================================= // Render // ========================================================================= const hasRuntimeFields = runtimeSchema?.properties && Object.keys(runtimeSchema.properties).length > 0; const viewsDisabled = loadingViews || (!loadingViews && availableViews.length === 0); // When ``x-singleView: true`` the runtime form fully describes a single // semantic view (e.g. a MetricFlow cube). Hide the picker and auto-select // whatever ``get_semantic_views`` returned so the Add button can fire // without an extra user click. const singleViewMode = (runtimeSchema as Record | null)?.['x-singleView'] === true; useEffect(() => { if (!singleViewMode) return; const namesToAdd = availableViews .filter(v => !v.already_added) .map(v => v.name); setSelectedViewNames(prev => { if ( prev.length === namesToAdd.length && prev.every((n, i) => n === namesToAdd[i]) ) { return prev; } return namesToAdd; }); }, [singleViewMode, availableViews]); return ( } width={MODAL_STANDARD_WIDTH} saveDisabled={newViewCount === 0 || saving} saveText={newViewCount > 0 ? t('Add %s view(s)', newViewCount) : t('Add')} saveLoading={saving} > {/* Semantic Layer */} setSelectedViewNames(values as string[])} options={availableViews .sort((a, b) => a.name.localeCompare(b.name)) .map(v => ({ value: v.name, label: v.already_added ? `${v.name} (${t('already added')})` : v.name, disabled: v.already_added, }))} getPopupContainer={() => document.body} /> )} ); }