mirror of
https://github.com/apache/superset.git
synced 2026-06-09 17:49:26 +00:00
Compare commits
8 Commits
fix/smtp-s
...
sl-fixes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bfb6351e5 | ||
|
|
86fe4b6ee3 | ||
|
|
c6842454b0 | ||
|
|
2c38326bca | ||
|
|
8a3e0670c7 | ||
|
|
42955ea637 | ||
|
|
02dd2d48c6 | ||
|
|
4c682318ec |
@@ -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')}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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, () => {
|
||||
|
||||
Reference in New Issue
Block a user