diff --git a/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx index bc5c5f54cca..6fbdb8ea283 100644 --- a/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx +++ b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx @@ -72,10 +72,53 @@ function ConstControl({ data, handleChange, path, schema }: ControlProps) { } const ConstRenderer = withJsonFormsControlProps(ConstControl); const constEntry = { - tester: rankWith(10, schemaMatches(s => s !== undefined && 'const' in s)), + tester: rankWith( + 10, + schemaMatches( + s => + s !== undefined && + 'const' in s && + !(s as Record).readOnly, + ), + ), renderer: ConstRenderer, }; +/** + * Renderer for read-only fields (e.g. a fixed database that the admin locked). + * Renders a disabled input showing the current value. Also ensures the default + * value is injected into form data (like ConstControl does for hidden fields). + */ +function ReadOnlyControl({ + data, + handleChange, + path, + schema, + ...rest +}: ControlProps) { + const defaultValue = + (schema as Record).const ?? + (schema as Record).default; + useEffect(() => { + if (defaultValue !== undefined && data !== defaultValue) { + handleChange(path, defaultValue); + } + }, [defaultValue, data, handleChange, path]); + + return TextControl({ ...rest, data, handleChange, path, schema, enabled: false }); +} +const ReadOnlyRenderer = withJsonFormsControlProps(ReadOnlyControl); +const readOnlyEntry = { + tester: rankWith( + 11, + schemaMatches( + s => + s !== undefined && (s as Record).readOnly === true, + ), + ), + renderer: ReadOnlyRenderer, +}; + /** * Checks whether all dependency values are filled (non-empty). * Handles nested objects (like auth) by checking they have at least one key. @@ -138,6 +181,7 @@ export const renderers = [ ...rendererRegistryEntries, passwordEntry, constEntry, + readOnlyEntry, dynamicFieldEntry, ]; @@ -163,7 +207,7 @@ export function sanitizeSchema(schema: JsonSchema): JsonSchema { properties[key] = prop as JsonSchema; } } - return { ...schema, properties }; + return { ...schema, properties } as JsonSchema; } /** diff --git a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx index f7ee1b110c2..16c16f178d4 100644 --- a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx +++ b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx @@ -20,7 +20,7 @@ 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 { Checkbox, Spin } from 'antd'; +import { Spin } from 'antd'; import { Select } from '@superset-ui/core/components'; import { Icons } from '@superset-ui/core/components/Icons'; import { JsonForms } from '@jsonforms/react'; @@ -31,7 +31,6 @@ import { StandardModal, ModalFormField, MODAL_STANDARD_WIDTH, - MODAL_MEDIUM_WIDTH, } from 'src/components/Modal'; import { renderers, @@ -43,8 +42,6 @@ import { SCHEMA_REFRESH_DEBOUNCE_MS, } from 'src/features/semanticLayers/jsonFormsHelpers'; -type Step = 'layer' | 'configure' | 'select'; - interface SemanticLayerOption { uuid: string; name: string; @@ -59,33 +56,57 @@ 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; + 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 { @@ -103,34 +124,43 @@ export default function AddSemanticViewModal({ addDangerToast, addSuccessToast, }: AddSemanticViewModalProps) { - const [step, setStep] = useState('layer'); + // --- Layer --- const [layers, setLayers] = useState([]); const [selectedLayerUuid, setSelectedLayerUuid] = useState( null, ); - const [loading, setLoading] = useState(false); - const [saving, setSaving] = useState(false); + const [loadingLayers, setLoadingLayers] = useState(false); - // Step 2: Configure (runtime schema) + // --- Runtime config --- const [runtimeSchema, setRuntimeSchema] = useState(null); const [runtimeUiSchema, setRuntimeUiSchema] = useState< UISchemaElement | undefined - >(undefined); + >(); const [runtimeData, setRuntimeData] = useState>({}); + const [loadingRuntime, setLoadingRuntime] = useState(false); const [refreshingSchema, setRefreshingSchema] = useState(false); - const [hasRuntimeErrors, setHasRuntimeErrors] = useState(false); const errorsRef = useRef([]); - const debounceTimerRef = useRef | null>(null); - const lastDepSnapshotRef = useRef(''); const dynamicDepsRef = useRef>({}); + const lastDepSnapshotRef = useRef(''); + const schemaTimerRef = useRef | null>(null); - // Step 3: Select views + // --- Views --- const [availableViews, setAvailableViews] = useState([]); - const [selectedViews, setSelectedViews] = useState>(new Set()); + 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 () => { - setLoading(true); + setLoadingLayers(true); try { const { json } = await SupersetClient.get({ endpoint: '/api/v1/semantic_layer/', @@ -144,29 +174,27 @@ export default function AddSemanticViewModal({ } catch { addDangerToast(t('An error occurred while fetching semantic layers')); } finally { - setLoading(false); + setLoadingLayers(false); } }; const fetchViews = useCallback( - async (uuid: string, rData: Record) => { + async (uuid: string, rData: Record, gen: number) => { setLoadingViews(true); - setStep('select'); + setAvailableViews([]); + setSelectedViewNames([]); 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)), - ); + if (gen !== fetchGenRef.current) return; + setAvailableViews(json.result ?? []); } catch { + if (gen !== fetchGenRef.current) return; addDangerToast(t('An error occurred while fetching available views')); } finally { - setLoadingViews(false); + if (gen === fetchGenRef.current) setLoadingViews(false); } }, [addDangerToast], @@ -179,95 +207,73 @@ export default function AddSemanticViewModal({ dynamicDepsRef.current = getDynamicDependencies(rawSchema); }, []); - const fetchRuntimeSchema = useCallback( - async (uuid: string, currentRuntimeData?: Record) => { - const isInitialFetch = !currentRuntimeData; - if (isInitialFetch) setLoading(true); - else setRefreshingSchema(true); + 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: currentRuntimeData - ? { runtime_data: currentRuntimeData } - : {}, + jsonPayload: {}, }); + if (gen !== fetchGenRef.current) return; const schema = json.result; if ( - isInitialFetch && - (!schema || - !schema.properties || - Object.keys(schema.properties).length === 0) + !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'); + // No runtime config needed — fetch views right away + fetchViews(uuid, {}, gen); } else { applyRuntimeSchema(schema); } } catch { - if (isInitialFetch) { - addDangerToast( - t('An error occurred while fetching the runtime schema'), - ); - } + if (gen !== fetchGenRef.current) return; + addDangerToast( + t('An error occurred while fetching the runtime schema'), + ); } finally { - if (isInitialFetch) setLoading(false); - else setRefreshingSchema(false); + if (gen === fetchGenRef.current) setLoadingRuntime(false); } }, - [addDangerToast, applyRuntimeSchema, fetchViews], + [applyRuntimeSchema, fetchViews, addDangerToast], ); - // 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 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], - ); + // ========================================================================= + // Runtime form change — refresh dynamic fields or auto-fetch views + // ========================================================================= const handleRuntimeFormChange = useCallback( ({ @@ -279,34 +285,116 @@ export default function AddSemanticViewModal({ }) => { setRuntimeData(data); errorsRef.current = errors ?? []; - setHasRuntimeErrors(errorsRef.current.length > 0); - maybeRefreshRuntimeSchema(data); + + 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); + } }, - [maybeRefreshRuntimeSchema], + [selectedLayerUuid, applyRuntimeSchema, scheduleFetchViews], ); - const handleToggleView = (viewName: string, checked: boolean) => { - setSelectedViews(prev => { - const next = new Set(prev); - if (checked) { - next.add(viewName); - } else { - next.delete(viewName); - } - return next; - }); - }; + // 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 => selectedViews.has(v.name) && !v.already_added, + v => selectedViewNames.includes(v.name) && !v.already_added, ).length; - const handleAddViews = async () => { - if (!selectedLayerUuid) return; + const handleSave = async () => { + if (!selectedLayerUuid || newViewCount === 0) return; setSaving(true); try { const viewsToCreate = availableViews - .filter(v => selectedViews.has(v.name) && !v.already_added) + .filter(v => selectedViewNames.includes(v.name) && !v.already_added) .map(v => ({ name: v.name, semantic_layer_uuid: selectedLayerUuid, @@ -328,148 +416,97 @@ export default function AddSemanticViewModal({ } }; - const handleSave = () => { - if (step === 'layer' && selectedLayerUuid) { - fetchRuntimeSchema(selectedLayerUuid); - } else if (step === 'configure' && selectedLayerUuid) { - fetchViews(selectedLayerUuid, runtimeData); - } else if (step === 'select') { - handleAddViews(); - } - }; + // ========================================================================= + // Render + // ========================================================================= - 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 hasRuntimeFields = + runtimeSchema?.properties && + Object.keys(runtimeSchema.properties).length > 0; - 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; + const viewsDisabled = + loadingViews || (!loadingViews && availableViews.length === 0); return ( } - width={modalWidth} - saveDisabled={saveDisabled} - saveText={saveText} + width={MODAL_STANDARD_WIDTH} + saveDisabled={newViewCount === 0 || saving} + saveText={newViewCount > 0 ? t('Add %s view(s)', newViewCount) : t('Add')} saveLoading={saving} - contentLoading={loading} > - {step === 'layer' && ( - - + + {/* Semantic Layer */} + + setSelectedLayerUuid(value as string)} - options={layers.map(l => ({ - value: l.uuid, - label: l.name, - }))} + ariaLabel={t('Semantic views')} + placeholder={t('Select semantic views')} + mode="multiple" + loading={loadingViews} + disabled={viewsDisabled} + value={selectedViewNames} + onChange={values => 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} - dropdownAlign={{ - points: ['tl', 'bl'], - offset: [0, 4], - overflow: { adjustX: 0, adjustY: 1 }, - }} /> - - )} - - {step === 'configure' && ( - - - - {t('Back')} - - {runtimeSchema && ( - - )} - - )} - - {step === 'select' && ( - - - - {t('Back')} - - {loadingViews ? ( - - - - ) : ( - - {availableViews.map(view => ( - handleToggleView(view.name, e.target.checked)} - > - {view.name} - {view.already_added && ({t('Already added')})} - - ))} - {availableViews.length === 0 && !loadingViews && ( - {t('No views available')} - )} - - )} - - )} + )} + ); }