mirror of
https://github.com/apache/superset.git
synced 2026-05-07 17:04:58 +00:00
Improve design
This commit is contained in:
@@ -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<string, unknown>).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<string, unknown>).const ??
|
||||
(schema as Record<string, unknown>).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<string, unknown>).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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 <Form> —
|
||||
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<Step>('layer');
|
||||
// --- Layer ---
|
||||
const [layers, setLayers] = useState<SemanticLayerOption[]>([]);
|
||||
const [selectedLayerUuid, setSelectedLayerUuid] = useState<string | null>(
|
||||
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<JsonSchema | null>(null);
|
||||
const [runtimeUiSchema, setRuntimeUiSchema] = useState<
|
||||
UISchemaElement | undefined
|
||||
>(undefined);
|
||||
>();
|
||||
const [runtimeData, setRuntimeData] = useState<Record<string, unknown>>({});
|
||||
const [loadingRuntime, setLoadingRuntime] = useState(false);
|
||||
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[]>>({});
|
||||
const lastDepSnapshotRef = useRef('');
|
||||
const schemaTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Step 3: Select views
|
||||
// --- Views ---
|
||||
const [availableViews, setAvailableViews] = useState<AvailableView[]>([]);
|
||||
const [selectedViews, setSelectedViews] = useState<Set<string>>(new Set());
|
||||
const [selectedViewNames, setSelectedViewNames] = useState<string[]>([]);
|
||||
const [loadingViews, setLoadingViews] = useState(false);
|
||||
const viewsTimerRef = useRef<ReturnType<typeof setTimeout> | 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<string, unknown>) => {
|
||||
async (uuid: string, rData: Record<string, unknown>, 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<string, unknown>) => {
|
||||
const isInitialFetch = !currentRuntimeData;
|
||||
if (isInitialFetch) setLoading(true);
|
||||
else setRefreshingSchema(true);
|
||||
const scheduleFetchViews = useCallback(
|
||||
(uuid: string, data: Record<string, unknown>) => {
|
||||
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<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],
|
||||
);
|
||||
// =========================================================================
|
||||
// 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 (
|
||||
<StandardModal
|
||||
show={show}
|
||||
onHide={onHide}
|
||||
onSave={handleSave}
|
||||
title={title}
|
||||
title={t('Add Semantic View')}
|
||||
icon={<Icons.PlusOutlined />}
|
||||
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' && (
|
||||
<ModalContent>
|
||||
<ModalFormField label={t('Semantic Layer')}>
|
||||
<ModalContent>
|
||||
{/* Semantic Layer */}
|
||||
<ModalFormField label={t('Semantic Layer')}>
|
||||
<Select
|
||||
ariaLabel={t('Semantic layer')}
|
||||
placeholder={t('Select a semantic layer')}
|
||||
loading={loadingLayers}
|
||||
value={selectedLayerUuid}
|
||||
onChange={value => handleLayerChange(value as string)}
|
||||
options={layers.map(l => ({
|
||||
value: l.uuid,
|
||||
label: l.name,
|
||||
}))}
|
||||
getPopupContainer={() => document.body}
|
||||
/>
|
||||
</ModalFormField>
|
||||
|
||||
{/* Loading runtime schema */}
|
||||
{loadingRuntime && (
|
||||
<LoadingContainer>
|
||||
<Spin size="small" />
|
||||
</LoadingContainer>
|
||||
)}
|
||||
|
||||
{/* Source location (runtime config fields) */}
|
||||
{hasRuntimeFields && !loadingRuntime && (
|
||||
<>
|
||||
<SectionLabel>{t('Source location')}</SectionLabel>
|
||||
<VerticalFormFields>
|
||||
<JsonForms
|
||||
schema={runtimeSchema!}
|
||||
uischema={runtimeUiSchema}
|
||||
data={runtimeData}
|
||||
renderers={renderers}
|
||||
cells={cellRegistryEntries}
|
||||
config={{ refreshingSchema, formData: runtimeData }}
|
||||
validationMode="ValidateAndHide"
|
||||
onChange={handleRuntimeFormChange}
|
||||
/>
|
||||
</VerticalFormFields>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Semantic Views — always visible once a layer is selected */}
|
||||
{selectedLayerUuid && !loadingRuntime && (
|
||||
<ModalFormField label={t('Semantic Views')}>
|
||||
<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,
|
||||
}))}
|
||||
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 },
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
)}
|
||||
</ModalContent>
|
||||
</StandardModal>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user