Compare commits

...

8 Commits

Author SHA1 Message Date
Beto Dealmeida
8bfb6351e5 Add tests 2026-06-01 16:34:39 -04:00
Beto Dealmeida
86fe4b6ee3 Warning on export 2026-06-01 16:34:39 -04:00
Beto Dealmeida
c6842454b0 Auto populate SL name 2026-06-01 16:34:39 -04:00
Beto Dealmeida
2c38326bca Fix for enums 2026-06-01 16:34:39 -04:00
Beto Dealmeida
8a3e0670c7 Sort dropdown 2026-06-01 16:34:39 -04:00
Beto Dealmeida
42955ea637 Fix dep reset 2026-06-01 16:34:39 -04:00
Beto Dealmeida
02dd2d48c6 Fix SL edit 2026-06-01 16:34:39 -04:00
Beto Dealmeida
4c682318ec fix(semantic layers): small fixes 2026-06-01 16:34:39 -04:00
4 changed files with 277 additions and 15 deletions

View File

@@ -90,6 +90,10 @@ export default function SemanticLayerModal({
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastDepSnapshotRef = useRef<string>('');
const dynamicDepsRef = useRef<Record<string, string[]>>({});
// Tracks the most recent value we auto-populated into the Name field so we
// can overwrite it when the user switches type — but leave alone anything
// the user has hand-edited.
const autoFilledNameRef = useRef<string>('');
const fetchTypes = useCallback(async () => {
setLoading(true);
@@ -209,12 +213,21 @@ export default function SemanticLayerModal({
errorsRef.current = [];
lastDepSnapshotRef.current = '';
dynamicDepsRef.current = {};
autoFilledNameRef.current = '';
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
}
}, [show, fetchTypes, isEditMode, semanticLayerUuid, fetchExistingLayer]);
const handleStepAdvance = () => {
if (selectedType) {
// Pre-fill the Name with the type's display name so a user who just
// wants the defaults doesn't have to invent one. Skip the overwrite
// once the user has typed something the auto-fill didn't put there.
const type = types.find(t => t.id === selectedType);
if (type && (name === '' || name === autoFilledNameRef.current)) {
setName(type.name);
autoFilledNameRef.current = type.name;
}
fetchConfigSchema(selectedType);
}
};
@@ -261,8 +274,13 @@ export default function SemanticLayerModal({
}
};
// Edit mode skips the type-picker step. Gating on this prevents the brief
// flash of the Create modal's first step while the existing layer is being
// fetched.
const isTypeStep = step === 'type' && !isEditMode;
const handleSave = () => {
if (step === 'type') {
if (isTypeStep) {
handleStepAdvance();
} else {
// Trigger validation UI and submit only from explicit save action.
@@ -291,6 +309,11 @@ export default function SemanticLayerModal({
if (snapshot === lastDepSnapshotRef.current) return;
lastDepSnapshotRef.current = snapshot;
// Flip the loading state immediately so dependent fields are disabled
// through the debounce window — otherwise the user keeps seeing the
// stale options for ~500ms before the request even fires.
setRefreshingSchema(true);
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = setTimeout(() => {
fetchConfigSchema(selectedType, data);
@@ -307,12 +330,36 @@ export default function SemanticLayerModal({
data: Record<string, unknown>;
errors?: ErrorObject[];
}) => {
setFormData(data);
// When a dependency of a dynamic field changes, clear that field's
// value so we don't carry a stale selection across the refresh (e.g.
// ``schema=PUBLIC`` lingering after the user switches database).
const dynamicDeps = dynamicDepsRef.current;
let nextData = data;
if (Object.keys(dynamicDeps).length > 0) {
const cleared: Record<string, unknown> = {};
for (const [field, deps] of Object.entries(dynamicDeps)) {
// Self-deps don't count — a field shouldn't wipe its own value
// every time the user picks something in it.
const externalDeps = deps.filter(dep => dep !== field);
if (externalDeps.length === 0) continue;
const depsChanged = externalDeps.some(
dep => JSON.stringify(formData[dep]) !== JSON.stringify(data[dep]),
);
if (depsChanged && data[field] !== undefined && data[field] !== '') {
cleared[field] = undefined;
}
}
if (Object.keys(cleared).length > 0) {
nextData = { ...data, ...cleared };
}
}
setFormData(nextData);
errorsRef.current = errors ?? [];
setHasErrors(errorsRef.current.length > 0);
maybeRefreshSchema(data);
maybeRefreshSchema(nextData);
},
[maybeRefreshSchema],
[maybeRefreshSchema, formData],
);
const selectedTypeName =
@@ -320,7 +367,7 @@ export default function SemanticLayerModal({
const title = isEditMode
? t('Edit %s', selectedTypeName || t('Semantic Layer'))
: step === 'type'
: isTypeStep
? t('New Semantic Layer')
: t('Configure %s', selectedTypeName);
@@ -331,18 +378,16 @@ export default function SemanticLayerModal({
onSave={handleSave}
title={title}
icon={isEditMode ? <Icons.EditOutlined /> : <Icons.PlusOutlined />}
width={step === 'type' ? MODAL_STANDARD_WIDTH : MODAL_MEDIUM_WIDTH}
width={isTypeStep ? MODAL_STANDARD_WIDTH : MODAL_MEDIUM_WIDTH}
saveDisabled={
step === 'type' ? !selectedType : saving || !name.trim() || hasErrors
}
saveText={
step === 'type' ? undefined : isEditMode ? t('Save') : t('Create')
isTypeStep ? !selectedType : saving || !name.trim() || hasErrors
}
saveText={isTypeStep ? undefined : isEditMode ? t('Save') : t('Create')}
saveLoading={saving}
contentLoading={loading}
>
<ModalContent>
{step === 'type' ? (
{isTypeStep ? (
<ModalFormField label={t('Type')}>
<Select
ariaLabel={t('Semantic layer type')}

View File

@@ -170,12 +170,17 @@ export function areDependenciesSatisfied(
* 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.
*
* Enum-typed fields (e.g. the Snowflake ``schema`` dropdown) get an explicit
* Antd Select so the ``loading``/``disabled`` props work natively — wrapping
* TextControl with ``inputProps.suffix`` doesn't reach the underlying Select.
*/
function DynamicFieldControl(props: ControlProps) {
const { refreshingSchema, formData: cfgData } = props.config ?? {};
const deps = (props.schema as Record<string, unknown>)?.['x-dependsOn'];
const schema = props.schema as Record<string, unknown>;
const deps = schema?.['x-dependsOn'];
const refreshing =
refreshingSchema &&
!!refreshingSchema &&
Array.isArray(deps) &&
areDependenciesSatisfied(
deps as string[],
@@ -183,6 +188,47 @@ function DynamicFieldControl(props: ControlProps) {
props.rootSchema,
);
const enumValues = Array.isArray(schema.enum)
? (schema.enum as unknown[])
: undefined;
if (enumValues && enumValues.length > 0) {
// Honour ``x-enumNames`` when present so labels can differ from values
// (e.g. MetricFlow's mode picker maps "full" / "cube" to human strings).
const enumNames = Array.isArray(schema['x-enumNames'])
? (schema['x-enumNames'] as unknown[])
: undefined;
// The backend returns these as a set, so order is undefined. Sort by
// label so the dropdown is stable and alphabetised.
const options = enumValues
.map((value, index) => ({
value: value as string | number,
label:
enumNames?.[index] !== undefined
? String(enumNames[index])
: String(value),
}))
.sort((a, b) => a.label.localeCompare(b.label));
const tooltip = (props.uischema?.options as Record<string, unknown>)
?.tooltip as string | undefined;
const placeholder = (props.uischema?.options as Record<string, unknown>)
?.placeholderText as string | undefined;
return (
<Form.Item label={props.label} tooltip={tooltip}>
<Select
value={(props.data as string | number | undefined) ?? undefined}
onChange={value => props.handleChange(props.path, value)}
options={options}
style={{ width: '100%' }}
disabled={!props.enabled || refreshing}
loading={refreshing}
allowClear
placeholder={refreshing ? t('Loading...') : placeholder}
/>
</Form.Item>
);
}
if (!refreshing) {
return TextControl(props);
}
@@ -199,8 +245,12 @@ function DynamicFieldControl(props: ControlProps) {
}
const DynamicFieldRenderer = withJsonFormsControlProps(DynamicFieldControl);
const dynamicFieldEntry = {
// Rank 6 so we beat ``@great-expectations`` ``EnumControl`` (rank 4) — when
// a field is both ``x-dynamic`` and has an ``enum`` (e.g. the Snowflake
// ``schema`` dropdown), the plain EnumControl would otherwise win and
// bypass our loading / dependency-clearing behavior entirely.
tester: rankWith(
3,
6,
and(
isStringControl,
schemaMatches(

View File

@@ -281,6 +281,141 @@ test('clicking export calls handleResourceExport with dataset ID', async () => {
});
});
test('bulk export of only semantic-view rows shows danger toast and skips export', async () => {
const semanticView = {
...mockDatasets[0],
id: 100,
table_name: 'sl_only_view',
kind: 'semantic_view',
};
const addDangerToast = jest.fn();
mockDatasetListEndpoints({ result: [semanticView], count: 1 });
renderDatasetList(mockAdminUser, {
addDangerToast,
addSuccessToast: jest.fn(),
});
await waitFor(() => {
expect(screen.getByText(semanticView.table_name)).toBeInTheDocument();
});
const bulkSelectButton = screen.getByRole('button', {
name: /bulk select/i,
});
await userEvent.click(bulkSelectButton);
const bulkSelectControls = await screen.findByTestId('bulk-select-controls');
const table = screen.getByTestId('listview-table');
await within(table).findAllByRole('checkbox');
const row = (await within(table).findByText(semanticView.table_name)).closest(
'tr',
);
expect(row).toBeInTheDocument();
await userEvent.click(within(row!).getByRole('checkbox'));
await waitFor(() => {
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
/1 Selected/i,
);
});
const bulkExportButton = await within(bulkSelectControls).findByRole(
'button',
{ name: /^export$/i },
);
await userEvent.click(bulkExportButton);
await waitFor(() => {
expect(addDangerToast).toHaveBeenCalled();
});
expect(String(addDangerToast.mock.calls[0][0])).toMatch(/semantic view/i);
expect(mockHandleResourceExport).not.toHaveBeenCalled();
});
test('bulk export of mixed datasets and semantic views warns and exports only datasets', async () => {
const physicalDataset = {
...mockDatasets[0],
id: 1,
table_name: 'physical_table',
kind: 'physical',
};
const semanticView = {
...mockDatasets[1],
id: 100,
table_name: 'sl_mixed_view',
kind: 'semantic_view',
};
const addDangerToast = jest.fn();
mockDatasetListEndpoints({
result: [physicalDataset, semanticView],
count: 2,
});
renderDatasetList(mockAdminUser, {
addDangerToast,
addSuccessToast: jest.fn(),
});
await waitFor(() => {
expect(screen.getByText(physicalDataset.table_name)).toBeInTheDocument();
expect(screen.getByText(semanticView.table_name)).toBeInTheDocument();
});
const bulkSelectButton = screen.getByRole('button', {
name: /bulk select/i,
});
await userEvent.click(bulkSelectButton);
const bulkSelectControls = await screen.findByTestId('bulk-select-controls');
const table = screen.getByTestId('listview-table');
await within(table).findAllByRole('checkbox');
const physicalRow = (
await within(table).findByText(physicalDataset.table_name)
).closest('tr');
await userEvent.click(within(physicalRow!).getByRole('checkbox'));
await waitFor(() => {
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
/1 Selected/i,
);
});
const semanticRow = (
await within(table).findByText(semanticView.table_name)
).closest('tr');
await userEvent.click(within(semanticRow!).getByRole('checkbox'));
await waitFor(() => {
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
/2 Selected/i,
);
});
const bulkExportButton = await within(bulkSelectControls).findByRole(
'button',
{ name: /^export$/i },
);
await userEvent.click(bulkExportButton);
await waitFor(() => {
expect(addDangerToast).toHaveBeenCalled();
});
expect(String(addDangerToast.mock.calls[0][0])).toMatch(/skipped/i);
await waitFor(() => {
expect(mockHandleResourceExport).toHaveBeenCalledWith(
'dataset',
[physicalDataset.id],
expect.any(Function),
);
});
});
test('clicking duplicate opens modal and submits duplicate request', async () => {
const datasetToDuplicate = {
...mockDatasets[1],

View File

@@ -610,7 +610,39 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
const handleBulkDatasetExport = useCallback(
async (datasetsToExport: Dataset[]) => {
const ids = datasetsToExport.map(({ id }) => id);
// The combined Datasources list mixes regular datasets (SqlaTable) with
// semantic views, which live in their own table with an independent id
// sequence. The dataset export endpoint looks rows up by bare numeric
// id against ``tables`` only — passing a semantic-view id silently
// returns whatever SqlaTable happens to share that id. Until a proper
// semantic-view export path exists, partition the selection and only
// ship dataset ids over to ``/api/v1/dataset/export/``.
const datasetRows = datasetsToExport.filter(
({ kind }) => kind !== 'semantic_view',
);
const semanticViewCount = datasetsToExport.length - datasetRows.length;
if (datasetRows.length === 0) {
addDangerToast(
t(
'Exporting semantic views is not supported yet. ' +
'Deselect the semantic-view rows and try again.',
),
);
return;
}
if (semanticViewCount > 0) {
addDangerToast(
t(
'Exporting semantic views is not supported yet — ' +
'%s semantic-view row(s) were skipped.',
semanticViewCount,
),
);
}
const ids = datasetRows.map(({ id }) => id);
setPreparingExport(true);
try {
await handleResourceExport('dataset', ids, () => {